mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 16:13:23 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
"""Smoke tests for docsgpt/asgi.py.
|
|
|
|
The goal isn't to re-test Flask or FastMCP internals — it's to catch
|
|
regressions in the wiring: mounts resolve, CORS headers emit, lifespan
|
|
runs (without it, the /mcp session manager raises "Task group is not
|
|
initialized"), routing to ``/`` vs ``/mcp`` doesn't cross paths.
|
|
|
|
Uses ``starlette.testclient.TestClient`` because it boots the ASGI app
|
|
end-to-end and handles the lifespan protocol automatically — ``httpx``
|
|
alone does not run lifespan events, which would mask the exact kind of
|
|
misconfiguration this test suite exists to catch.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_asgi_app_imports():
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
assert asgi_app is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_flask_route_served_through_starlette_mount():
|
|
"""GET /api/health should reach the Flask app via a2wsgi and return 200."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.get("/api/health")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"status": "ok"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_mcp_endpoint_mounted_and_lifespan_runs():
|
|
"""/mcp must be reachable AND the FastMCP session manager must start.
|
|
|
|
Without ``lifespan=mcp_app.lifespan`` on the outer Starlette app,
|
|
every /mcp request raises ``RuntimeError: Task group is not
|
|
initialized``. Hitting the endpoint under a real lifespan-aware
|
|
client catches that.
|
|
"""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
# Minimal MCP initialize request. Doesn't need to succeed — we
|
|
# just need a non-404, non-500-with-RuntimeError response to
|
|
# confirm the mount + lifespan are both wired.
|
|
r = client.post(
|
|
"/mcp/",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
},
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "pytest", "version": "0"},
|
|
},
|
|
},
|
|
)
|
|
assert r.status_code != 404, f"/mcp mount unreachable: {r.status_code}"
|
|
# A successful initialize returns 200 with a Mcp-Session-Id header.
|
|
assert r.status_code == 200
|
|
assert "mcp-session-id" in {k.lower() for k in r.headers.keys()}
|
|
assert r.headers.get("access-control-expose-headers") == "Mcp-Session-Id"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_headers_on_flask_route():
|
|
"""CORS middleware should emit allow-origin on actual (non-preflight) requests.
|
|
|
|
``allow_origins=["*"]`` → header value is literal ``*`` (not an echo).
|
|
"""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.get("/api/health", headers={"Origin": "http://example.com"})
|
|
assert r.status_code == 200
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_on_flask_route():
|
|
"""OPTIONS preflight on a Flask route should be handled by Starlette CORSMiddleware."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/api/health",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Headers": "Content-Type",
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
assert "GET" in r.headers.get("access-control-allow-methods", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_allows_patch():
|
|
"""PATCH must be in Access-Control-Allow-Methods. The frontend's
|
|
apiClient.patch() (used to edit BYOM custom models via PATCH
|
|
/api/user/models/<id>) is otherwise blocked at preflight by browsers."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/api/health",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "PATCH",
|
|
"Access-Control-Request-Headers": "Content-Type, Authorization",
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert "PATCH" in r.headers.get("access-control-allow-methods", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_on_mcp_route():
|
|
"""Browser clients hitting /mcp should be allowed to send session headers."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from docsgpt.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/mcp/",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": (
|
|
"Authorization, Content-Type, Mcp-Session-Id"
|
|
),
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
assert "Mcp-Session-Id" in r.headers.get("access-control-allow-headers", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_wsgi_threadpool_sized_from_settings():
|
|
"""The Flask thread pool is the app's request-capacity ceiling —
|
|
it must be operator-tunable (prod incident 2026-07-05→07: 32 slots
|
|
exhausted by SSE holders starved all other requests)."""
|
|
from docsgpt import asgi
|
|
from docsgpt.core.settings import settings
|
|
|
|
assert asgi._WSGI_THREADPOOL == int(settings.WSGI_THREADPOOL_WORKERS)
|
|
assert asgi._WSGI_THREADPOOL >= 64
|