refactor(api): enable strict typing for web/openapi controller test files (#44890)

Co-authored-by: Felix Shi <204130665+shi-felix@users.noreply.github.com>
This commit is contained in:
Felix ShiandFelix Shi authored and GitHub committed 2026-10-29 12:37:20 +00:00
1 parent 8f7ef0ec04
commit b0be397f2e
8 files changed
+85 -63

No files matched your search

@@ -2,9 +2,10 @@
import uuid
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import (
BadGateway,
@@ -70,14 +71,14 @@ def fmt() -> OpenApiErrorFormatter:
class TestErrorBodyModel:
def test_minimal_body_serializes_without_optional_fields(self):
def test_minimal_body_serializes_without_optional_fields(self) -> None:
body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404)
wire = body.model_dump(mode="json", exclude_none=True)
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
def test_full_body_round_trips(self):
def test_full_body_round_trips(self) -> None:
body = ErrorBody(
code=OpenApiErrorCode.INVALID_PARAM,
message="Request validation failed",
@@ -91,7 +92,7 @@ class TestErrorBodyModel:
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
assert ErrorBody.model_validate(wire) == body
def test_code_field_is_open_string_for_forward_compat(self):
def test_code_field_is_open_string_for_forward_compat(self) -> None:
# Old CLIs must not hard-fail when a future server adds a code, so the
# schema type is str; enum membership is enforced by the formatter tests.
body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400})
@@ -99,12 +100,22 @@ class TestErrorBodyModel:
assert body.code == "some_future_code"
class _DataCarryingUnprocessableEntity(UnprocessableEntity):
"""flask_restx attaches ``abort(422, ...)`` kwargs as ``e.data`` at runtime.
Declaring the attribute keeps the type checker honest without an ignore, and
the instance is still the real ``UnprocessableEntity`` the framework raises.
"""
data: dict[str, object]
class TestOpenApiErrorFormatter:
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt):
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt: OpenApiErrorFormatter) -> None:
# Simulates _contract.py's abort(422, message=..., errors=...): flask_restx
# attaches kwargs to e.data, which handle_error would otherwise put on the
# wire verbatim (no code/status).
e = UnprocessableEntity()
e = _DataCarryingUnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}],
@@ -120,8 +131,8 @@ class TestOpenApiErrorFormatter:
# the override channel now carries the canonical body
assert e.data == wire
def test_finalize_is_idempotent(self, fmt):
e = UnprocessableEntity()
def test_finalize_is_idempotent(self, fmt: OpenApiErrorFormatter) -> None:
e = _DataCarryingUnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}],
@@ -133,10 +144,10 @@ class TestOpenApiErrorFormatter:
assert second == first
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt):
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt: OpenApiErrorFormatter) -> None:
# finalize runs inside the framework error handler; a ValidationError
# escaping it would replace the response with an unformatted 500
e = UnprocessableEntity()
e = _DataCarryingUnprocessableEntity()
e.data = {"message": "broken", "details": [{"bad": "shape"}]}
data = {"code": "unprocessable_entity", "message": "broken", "status": 422}
@@ -144,7 +155,7 @@ class TestOpenApiErrorFormatter:
assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422}
def test_params_shape_becomes_details(self, fmt):
def test_params_shape_becomes_details(self, fmt: OpenApiErrorFormatter) -> None:
e = ValueError("is required")
data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400}
@@ -153,7 +164,7 @@ class TestOpenApiErrorFormatter:
assert "params" not in wire
assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}]
def test_catch_all_exception_never_leaks_str_e(self, fmt):
def test_catch_all_exception_never_leaks_str_e(self, fmt: OpenApiErrorFormatter) -> None:
e = RuntimeError("postgres password=hunter2 connection refused")
data = {"message": str(e), "code": "unknown", "status": 500}
@@ -162,7 +173,7 @@ class TestOpenApiErrorFormatter:
assert wire["code"] == "internal_server_error"
assert "hunter2" not in wire["message"]
def test_unmapped_status_falls_back_to_unknown(self, fmt):
def test_unmapped_status_falls_back_to_unknown(self, fmt: OpenApiErrorFormatter) -> None:
from werkzeug.exceptions import Gone
e = Gone()
@@ -173,7 +184,7 @@ class TestOpenApiErrorFormatter:
assert wire["code"] == "unknown"
@pytest.mark.parametrize("message", [None, "custom reason"], ids=["class_description", "call_site_override"])
def test_openapi_error_subclass_is_throw_and_done(self, fmt, message):
def test_openapi_error_subclass_is_throw_and_done(self, fmt: OpenApiErrorFormatter, message: str) -> None:
# The dedicated throwable: subclass declares status + code + message once,
# call sites just `raise`, or pass a message to override the description;
# the formatter emits everything verbatim.
@@ -194,7 +205,9 @@ class TestOpenApiErrorFormatter:
class TestQuotaExceptions:
@pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded])
def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class):
def test_quota_exception_carries_declared_code_and_message(
self, fmt: OpenApiErrorFormatter, exc_class: type[OpenApiError]
) -> None:
# Single source: assertions read the class attributes, no re-typed strings.
e = exc_class()
data = {"code": "forbidden", "message": e.description, "status": 403}
@@ -211,8 +224,8 @@ class TestWireContract:
"""End-to-end: request in, canonical JSON out, through the real openapi blueprint."""
def test_accepts_422_carries_code_status_details(
self, openapi_app, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
):
self, openapi_app: Flask, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`@endpoint` runs auth outermost, so reaching `@accepts` means passing
it: one account row and a stubbed authenticator are the whole world
`GET /account/sessions` needs.
@@ -250,7 +263,7 @@ class TestWireContract:
assert wire["status"] == 422
assert wire["details"]
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app):
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app: Flask) -> None:
client = openapi_app.test_client()
resp = client.get("/openapi/v1/definitely-not-a-route")
@@ -261,7 +274,7 @@ class TestWireContract:
assert wire["code"] == "not_found"
assert "did you mean" not in wire["message"].lower()
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app):
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app: Flask) -> None:
# catch_all_404s wraps the app-level exception handler; the prefix
# guard must keep non-/openapi/v1 paths on the app's own 404 handling
client = openapi_app.test_client()
@@ -273,7 +286,9 @@ class TestWireContract:
assert "application/json" not in (resp.content_type or "")
@patch("controllers.openapi.oauth_device.application_services")
def test_oauth_device_token_keeps_rfc8628_shape(self, mock_application_services, openapi_app):
def test_oauth_device_token_keeps_rfc8628_shape(
self, mock_application_services: MagicMock, openapi_app: Flask
) -> None:
mock_application_services.return_value.oauth_device.poll.side_effect = ExpiredTokenError
client = openapi_app.test_client()
@@ -333,7 +348,9 @@ class TestErrorMatrix:
ERROR_MATRIX,
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
)
def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code):
def test_every_known_error_path_yields_canonical_code(
self, fmt: OpenApiErrorFormatter, exc: Exception, status: int, expected_code: str
) -> None:
data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status})
wire = fmt.finalize(exc, data, status)
@@ -345,7 +362,7 @@ class TestErrorMatrix:
class TestErrorCodeEnumRegistration:
def test_enum_registered_with_all_values(self):
def test_enum_registered_with_all_values(self) -> None:
from controllers.openapi import openapi_ns
from controllers.openapi._errors import OpenApiErrorCode
@@ -33,7 +33,7 @@ def test_revoke_by_id_hides_a_session_the_caller_does_not_own(
_stub_account_service(monkeypatch)
with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"):
with pytest.raises(NotFound, match="session not found"):
api.delete.__handler__(api, _ctx(), session_id=session_id)
api.delete.__handler__(api, _ctx(), session_id=session_id) # type: ignore[attr-defined]
# --- GET /account/sessions query validation. The application service is replaced
@@ -61,12 +61,12 @@ def _stub_account_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(mod, "application_services", lambda: services)
def test_sessions_list_valid_query_parses_page_and_limit(app: Flask, monkeypatch: pytest.MonkeyPatch):
def test_sessions_list_valid_query_parses_page_and_limit(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""A valid page/limit round-trips through SessionListQuery into the response envelope."""
api = AccountSessionsApi()
_stub_account_service(monkeypatch)
with app.test_request_context("/openapi/v1/account/sessions?page=2&limit=5"):
result = api.get.__handler__(api, _ctx(), query=SessionListQuery(page=2, limit=5))
result = api.get.__handler__(api, _ctx(), query=SessionListQuery(page=2, limit=5)) # type: ignore[attr-defined]
assert result.page == 2
assert result.limit == 5
assert result.total == 0
@@ -82,6 +82,6 @@ def test_sessions_list_valid_query_parses_page_and_limit(app: Flask, monkeypatch
{"foo": "bar"},
],
)
def test_session_list_query_rejects_out_of_bounds(params):
def test_session_list_query_rejects_out_of_bounds(params: dict[str, object]) -> None:
with pytest.raises(ValidationError):
SessionListQuery.model_validate(params)
@@ -17,9 +17,10 @@ from controllers.web.error import (
WebAppAuthRequiredError,
WebAppNotFoundError,
)
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.app_config.common.parameters_mapping import AppParametersDict, get_parameters_from_feature_dict
from enums import WebAppAccessMode
from extensions.ext_application_services import ApplicationServices
from libs.exception import BaseHTTPException
from libs.passport import PassportService
from models.enums import EndUserType
from models.model import App, AppMode, EndUser
@@ -79,7 +80,7 @@ class TestAppParameterApi:
) -> None:
calls: list[str] = []
def get_public_parameters(app_id: str):
def get_public_parameters(app_id: str) -> AppParametersDict:
calls.append(app_id)
return get_parameters_from_feature_dict(
features_dict={"opening_statement": "Hello"},
@@ -233,7 +234,7 @@ class TestAppAccessMode:
real_application_services: ApplicationServices,
monkeypatch: pytest.MonkeyPatch,
service_error: Exception,
http_error: type[Exception],
http_error: type[BaseHTTPException],
expected_data: dict[str, object],
app: Flask,
) -> None:
@@ -83,7 +83,8 @@ class TestCompletionApi:
@patch("controllers.web.completion.AppGenerateService.generate")
@patch("controllers.web.completion.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "test"}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs, "query": "test"}
mock_gen.return_value = "response-obj"
with app.test_request_context("/completion-messages", method="POST"):
@@ -97,7 +98,8 @@ class TestCompletionApi:
)
@patch("controllers.web.completion.web_ns")
def test_provider_not_init_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderNotInitializeError):
@@ -109,7 +111,8 @@ class TestCompletionApi:
)
@patch("controllers.web.completion.web_ns")
def test_quota_exceeded_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderQuotaExceededError):
@@ -121,7 +124,8 @@ class TestCompletionApi:
)
@patch("controllers.web.completion.web_ns")
def test_model_not_support_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
@@ -159,7 +163,8 @@ class TestChatApi:
@patch("controllers.web.completion.AppGenerateService.generate")
@patch("controllers.web.completion.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "hi"}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs, "query": "hi"}
mock_gen.return_value = "response"
with app.test_request_context("/chat-messages", method="POST"):
@@ -173,7 +178,8 @@ class TestChatApi:
)
@patch("controllers.web.completion.web_ns")
def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "x"}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs, "query": "x"}
with app.test_request_context("/chat-messages", method="POST"):
with pytest.raises(CompletionRequestError):
@@ -185,7 +191,8 @@ class TestChatApi:
)
@patch("controllers.web.completion.web_ns")
def test_agent_not_published_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "x"}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs, "query": "x"}
app_model = _app(AppMode.AGENT)
with app.test_request_context("/chat-messages", method="POST"):
@@ -203,7 +210,8 @@ class TestChatApi:
app: Flask,
unbound_session: Session,
) -> None:
mock_ns.payload = {"inputs": {}, "query": "hi", "conversation_id": str(uuid.uuid4())}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs, "query": "hi", "conversation_id": str(uuid.uuid4())}
session = unbound_session
with app.test_request_context("/chat-messages", method="POST"):
@@ -125,7 +125,7 @@ class TestCompletionMessagePayload:
def test_invalid_response_mode(self) -> None:
with pytest.raises(ValidationError):
CompletionMessagePayload(inputs={}, response_mode="invalid")
CompletionMessagePayload(inputs={}, response_mode="invalid") # pyrefly: ignore[bad-argument-type]
class TestChatMessagePayload:
@@ -151,7 +151,7 @@ class TestChatMessagePayload:
def test_query_required(self) -> None:
with pytest.raises(ValidationError):
ChatMessagePayload(inputs={})
ChatMessagePayload(inputs={}) # pyrefly: ignore[missing-argument]
class TestConversationListQuery:
@@ -164,11 +164,11 @@ class TestConversationListQuery:
def test_limit_lower_bound(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(limit=0)
ConversationListQuery(limit=0) # pyrefly: ignore[bad-argument-type]
def test_limit_upper_bound(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(limit=101)
ConversationListQuery(limit=101) # pyrefly: ignore[bad-argument-type]
def test_limit_boundaries_valid(self) -> None:
assert ConversationListQuery(limit=1).limit == 1
@@ -180,7 +180,7 @@ class TestConversationListQuery:
def test_invalid_sort_by(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(sort_by="invalid")
ConversationListQuery(sort_by="invalid") # pyrefly: ignore[bad-argument-type]
def test_valid_last_id(self) -> None:
uid = str(uuid4())
@@ -229,9 +229,9 @@ class TestMessageListQuery:
def test_limit_bounds(self) -> None:
cid = str(uuid4())
with pytest.raises(ValidationError):
MessageListQuery(conversation_id=cid, limit=0)
MessageListQuery(conversation_id=cid, limit=0) # pyrefly: ignore[bad-argument-type]
with pytest.raises(ValidationError):
MessageListQuery(conversation_id=cid, limit=101)
MessageListQuery(conversation_id=cid, limit=101) # pyrefly: ignore[bad-argument-type]
def test_valid_first_id(self) -> None:
cid = str(uuid4())
@@ -257,7 +257,7 @@ class TestMessageFeedbackPayload:
def test_invalid_rating(self) -> None:
with pytest.raises(ValidationError):
MessageFeedbackPayload(rating="neutral")
MessageFeedbackPayload(rating="neutral") # pyrefly: ignore[bad-argument-type]
class TestMessageMoreLikeThisQuery:
@@ -267,11 +267,11 @@ class TestMessageMoreLikeThisQuery:
def test_invalid_mode(self) -> None:
with pytest.raises(ValidationError):
MessageMoreLikeThisQuery(response_mode="invalid")
MessageMoreLikeThisQuery(response_mode="invalid") # pyrefly: ignore[bad-argument-type]
def test_required(self) -> None:
with pytest.raises(ValidationError):
MessageMoreLikeThisQuery()
MessageMoreLikeThisQuery() # pyrefly: ignore[missing-argument]
class TestRemoteFileUploadPayload:
@@ -286,7 +286,7 @@ class TestRemoteFileUploadPayload:
def test_url_required(self) -> None:
with pytest.raises(ValidationError):
RemoteFileUploadPayload()
RemoteFileUploadPayload() # pyrefly: ignore[missing-argument]
class TestSavedMessageListQuery:
@@ -297,9 +297,9 @@ class TestSavedMessageListQuery:
def test_limit_bounds(self) -> None:
with pytest.raises(ValidationError):
SavedMessageListQuery(limit=0)
SavedMessageListQuery(limit=0) # pyrefly: ignore[bad-argument-type]
with pytest.raises(ValidationError):
SavedMessageListQuery(limit=101)
SavedMessageListQuery(limit=101) # pyrefly: ignore[bad-argument-type]
def test_valid_last_id(self) -> None:
uid = str(uuid4())
@@ -319,7 +319,7 @@ class TestSavedMessageCreatePayload:
def test_required(self) -> None:
with pytest.raises(ValidationError):
SavedMessageCreatePayload()
SavedMessageCreatePayload() # pyrefly: ignore[missing-argument]
class TestWorkflowRunPayload:
@@ -334,7 +334,7 @@ class TestWorkflowRunPayload:
def test_inputs_required(self) -> None:
with pytest.raises(ValidationError):
WorkflowRunPayload()
WorkflowRunPayload() # pyrefly: ignore[missing-argument]
class TestForgotPasswordSendPayload:
@@ -202,7 +202,7 @@ def test_unavailable_identity_is_canonical_404_before_authentication(
id=app_id,
tenant_id=app_id,
name="Private fixture",
mode="chat",
mode="chat", # pyrefly: ignore[bad-argument-type]
enable_site=condition != "app-disabled",
enable_api=True,
app_model_config_id=config_id if condition != "unpublished" else None,
@@ -61,7 +61,8 @@ class TestWorkflowRunApi:
)
@patch("controllers.web.workflow.web_ns")
def test_provider_not_init(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(ProviderNotInitializeError):
@@ -78,7 +79,8 @@ class TestWorkflowRunApi:
mock_gen: MagicMock,
app: Flask,
) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
@@ -93,7 +95,8 @@ class TestWorkflowRunApi:
)
@patch("controllers.web.workflow.web_ns")
def test_quota_exceeded(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
empty_inputs: dict[str, object] = {}
mock_ns.payload = {"inputs": empty_inputs}
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(ProviderQuotaExceededError):
-7
View File
@@ -93,11 +93,9 @@ project-excludes = [
"controllers/inner_api/test_mail.py",
"controllers/inner_api/test_runtime_credentials.py",
"controllers/mcp/test_mcp.py",
"controllers/openapi/test_account.py",
"controllers/openapi/test_app_run.py",
"controllers/openapi/test__contract.py",
"controllers/openapi/test_oauth_device.py",
"controllers/openapi/test__errors.py",
"controllers/openapi/test_human_input_form.py",
"controllers/openapi/test__models.py",
"controllers/openapi/test_workflow_events.py",
@@ -122,14 +120,9 @@ project-excludes = [
"controllers/service_api/test___init__.py",
"controllers/trigger/test_trigger.py",
"controllers/trigger/test_webhook.py",
"controllers/web/test_app.py",
"controllers/web/test_completion.py",
"controllers/web/test_human_input_file_upload.py",
"controllers/web/test_human_input_form.py",
"controllers/web/test_message.py",
"controllers/web/test_conversation.py",
"controllers/web/test_login.py",
"controllers/web/test_workflow.py",
"controllers/web/test_wraps.py",
"core/agent/conftest.py",
"core/agent/output_parser/test_cot_output_parser.py",