| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655 |
- """
- Integration tests for API and WebUI path prefix support via root_path.
- With the root_path approach, routes always stay at their natural paths
- (/docs, /health, /query, /documents/...). The api_prefix is passed to
- FastAPI's root_path parameter, which controls the servers URL in the
- OpenAPI spec for correct reverse proxy operation.
- """
- import os
- import sys
- from unittest.mock import patch, MagicMock
- from fastapi.testclient import TestClient
- import pytest
- # Env vars that the project's `.env` may have populated (via load_dotenv at
- # import time of lightrag.api.config). Tests must be hermetic and not depend
- # on developer-local .env values, so we clear/override anything that affects
- # parse_args() / create_app().
- _ENV_VARS_TO_ISOLATE = (
- "LLM_BINDING",
- "EMBEDDING_BINDING",
- "LLM_BINDING_HOST",
- "LLM_BINDING_API_KEY",
- "LLM_MODEL",
- "EMBEDDING_BINDING_HOST",
- "EMBEDDING_BINDING_API_KEY",
- "EMBEDDING_MODEL",
- "LIGHTRAG_API_PREFIX",
- "LIGHTRAG_KV_STORAGE",
- "LIGHTRAG_VECTOR_STORAGE",
- "LIGHTRAG_GRAPH_STORAGE",
- "LIGHTRAG_DOC_STATUS_STORAGE",
- )
- @pytest.fixture(autouse=True)
- def _isolate_env(monkeypatch):
- """Isolate tests from developer-local .env pollution.
- The lightrag.api.config module loads .env at import time, which can leave
- bindings/hosts/keys in os.environ that mismatch what these tests assume.
- Clear them, then set the minimal viable defaults (ollama bindings) so
- create_app's binding validation passes without touching real services.
- """
- for var in _ENV_VARS_TO_ISOLATE:
- monkeypatch.delenv(var, raising=False)
- monkeypatch.setenv("LLM_BINDING", "ollama")
- monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
- @pytest.fixture
- def mock_args_api_prefix():
- """Create mock args with API prefix."""
- from lightrag.api.config import parse_args
- original_argv = sys.argv.copy()
- try:
- sys.argv = ["lightrag-server", "--api-prefix", "/test-api"]
- args = parse_args()
- yield args
- finally:
- sys.argv = original_argv
- @pytest.fixture
- def mock_args_no_prefix():
- """Create mock args without API prefix."""
- from lightrag.api.config import parse_args
- original_argv = sys.argv.copy()
- try:
- sys.argv = ["lightrag-server"]
- args = parse_args()
- yield args
- finally:
- sys.argv = original_argv
- class TestRootPathConfiguration:
- """Test that root_path is set correctly on the FastAPI app."""
- def test_root_path_set_when_prefix_provided(self, mock_args_api_prefix):
- """Test app.root_path reflects api_prefix."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- assert app.root_path == "/test-api"
- def test_root_path_none_when_no_prefix(self, mock_args_no_prefix):
- """Test app.root_path is not set when no prefix is configured."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_no_prefix)
- # When no prefix, root_path is None (not passed to FastAPI)
- # FastAPI stores None as-is, which means no root_path injection
- assert not app.root_path
- class TestRoutesAtNaturalPaths:
- """Test that routes stay at their natural paths regardless of root_path."""
- def test_routes_accessible_at_both_paths_with_prefix(self, mock_args_api_prefix):
- """With root_path, routes work at both prefixed and natural paths.
- FastAPI injects root_path into the ASGI scope, and Starlette strips
- it from the path before matching. So /test-api/docs and /docs both work.
- """
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- client = TestClient(app)
- # Natural path works
- response = client.get("/docs")
- assert response.status_code == 200
- response = client.get("/openapi.json")
- assert response.status_code == 200
- # Prefixed path also works (FastAPI strips root_path from scope)
- response = client.get("/test-api/docs")
- assert response.status_code == 200
- response = client.get("/test-api/openapi.json")
- assert response.status_code == 200
- def test_document_routes_at_natural_path(self, mock_args_api_prefix):
- """Test document routes are at /documents/ (their router-level prefix)."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- client = TestClient(app)
- response = client.post(
- "/documents/paginated",
- json={},
- headers={"Authorization": "Bearer test"},
- )
- # The route is mounted; the mocked LightRAG may cause 401/422/500,
- # but a missing route (404) or wrong method (405) means routing
- # itself broke and is what we want to catch here.
- assert response.status_code not in (404, 405)
- def test_routes_accessible_at_root_no_prefix(self, mock_args_no_prefix):
- """Test routes are at root when no prefix is set (default)."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_no_prefix)
- client = TestClient(app)
- # API docs accessible at root
- response = client.get("/docs")
- assert response.status_code == 200
- # openapi.json at root
- response = client.get("/openapi.json")
- assert response.status_code == 200
- # Prefixed paths return 404 when no root_path is configured
- response = client.get("/test-api/docs")
- assert response.status_code == 404
- class TestOpenAPISpecIntegration:
- """Test that OpenAPI spec uses root_path for servers URL."""
- def test_openapi_spec_has_servers_url_with_prefix(self, mock_args_api_prefix):
- """Test OpenAPI spec servers URL includes the prefix via root_path."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- client = TestClient(app)
- # OpenAPI JSON is served at the natural path
- response = client.get("/openapi.json")
- assert response.status_code == 200
- spec = response.json()
- # Servers URL should include the prefix
- servers = spec.get("servers", [])
- assert (
- len(servers) > 0
- ), "OpenAPI spec should have servers entry when root_path is set"
- assert (
- servers[0].get("url") == "/test-api"
- ), f"Expected servers URL to be exactly /test-api, got: {servers[0].get('url')}"
- def test_openapi_spec_no_servers_without_prefix(self, mock_args_no_prefix):
- """Test OpenAPI spec has no servers entry when no root_path."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_no_prefix)
- client = TestClient(app)
- response = client.get("/openapi.json")
- assert response.status_code == 200
- spec = response.json()
- # No servers when root_path is None/empty
- assert "servers" not in spec or spec["servers"] is None
- def test_openapi_spec_paths_at_natural_paths(self, mock_args_api_prefix):
- """Test OpenAPI spec paths are at natural paths (not prefixed)."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- client = TestClient(app)
- response = client.get("/openapi.json")
- assert response.status_code == 200
- spec = response.json()
- paths = spec.get("paths", {})
- # Paths should be at natural paths
- for path in paths:
- if path == "/":
- continue
- assert not path.startswith(
- "/test-api/"
- ), f"Path {path} should not be prefixed with /test-api/ in root_path mode"
- class TestWebUIPrefixIntegration:
- """Test that the WebUI is served at the expected (fixed) /webui path,
- composed with `root_path` when an API prefix is set."""
- def test_webui_at_prefixed_path(self, mock_args_api_prefix):
- """With root_path="/test-api" the WebUI lives at /test-api/webui/
- because FastAPI injects root_path into the ASGI scope."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_api_prefix)
- client = TestClient(app)
- response = client.get("/test-api/webui/")
- assert response.status_code in [200, 307]
- def test_webui_without_api_prefix(self, mock_args_no_prefix):
- """Without an API prefix the WebUI is served at /webui/."""
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- from lightrag.api.lightrag_server import create_app
- app = create_app(mock_args_no_prefix)
- client = TestClient(app)
- response = client.get("/webui/")
- assert response.status_code in [200, 307]
- class TestEnvironmentVariables:
- """Test that environment variables are read correctly."""
- def test_env_api_prefix(self):
- """Test LIGHTRAG_API_PREFIX environment variable."""
- from lightrag.api.config import get_env_value
- os.environ["LIGHTRAG_API_PREFIX"] = "unit-test-back/api"
- try:
- value = get_env_value("LIGHTRAG_API_PREFIX", "")
- assert value == "unit-test-back/api"
- finally:
- del os.environ["LIGHTRAG_API_PREFIX"]
- class TestPathNormalization:
- """User input for `--api-prefix` may contain trailing slashes, a missing
- leading slash, or be just '/'. create_app must canonicalize these before
- passing to FastAPI's `root_path`, which doesn't accept arbitrary strings."""
- def _build(self, *cli_args):
- # sys.argv must be the lightrag-server form *before* lightrag_server is
- # imported, because importing lightrag.api.utils_api evaluates
- # `global_args.whitelist_paths` at module top level, which triggers
- # parse_args() against whatever sys.argv currently holds.
- original_argv = sys.argv.copy()
- try:
- sys.argv = ["lightrag-server", *cli_args]
- from lightrag.api.config import parse_args
- from lightrag.api.lightrag_server import create_app
- args = parse_args()
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- return create_app(args)
- finally:
- sys.argv = original_argv
- def test_api_prefix_slash_only_treated_as_empty(self):
- """`--api-prefix /` is degenerate; must collapse to no prefix."""
- app = self._build("--api-prefix", "/")
- assert not app.root_path
- def test_api_prefix_trailing_slash_stripped(self):
- """Trailing slash on api_prefix is stripped to keep OpenAPI servers
- URL clean and avoid double-slash artifacts."""
- app = self._build("--api-prefix", "/api/v1/")
- assert app.root_path == "/api/v1"
- def test_api_prefix_missing_leading_slash_added(self):
- app = self._build("--api-prefix", "api/v1")
- assert app.root_path == "/api/v1"
- class TestRuntimeConfigInjection:
- """End-to-end tests for the WebUI runtime-config injection.
- The browser-visible URL prefixes are no longer baked into the bundle.
- Instead, the server replaces a placeholder comment in index.html with
- a `<script>window.__LIGHTRAG_CONFIG__ = {...}</script>` snippet on
- every HTML response, so one build can serve any reverse-proxy mount.
- These tests stage a minimal index.html in a tmp dir, patch
- `lightrag_server.__file__` so both `check_frontend_build()` and the
- static-files mount resolve to it, then drive the app via TestClient
- and assert that the body contains the expected injected JSON.
- """
- PLACEHOLDER = "<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->"
- def _stage_index_html(self, tmp_path, *, with_placeholder=True):
- """Mirror what Vite emits: a tiny index.html with the runtime-config
- placeholder in <head> plus a hashed asset reference.
- with_placeholder=False simulates a stale build that pre-dates this
- feature — the server should serve it untouched, not crash.
- """
- webui_dir = tmp_path / "webui"
- webui_dir.mkdir()
- placeholder = self.PLACEHOLDER if with_placeholder else ""
- (webui_dir / "index.html").write_text(
- "<!doctype html><html><head>"
- f"{placeholder}"
- '<script type="module" crossorigin src="./assets/index-X.js"></script>'
- '<link rel="stylesheet" href="./assets/index-X.css">'
- "</head><body><div id=root></div></body></html>",
- encoding="utf-8",
- )
- def _build_app(self, tmp_path, monkeypatch, *cli_args):
- # Force benign argv before the (potentially fresh) module import —
- # see TestPathNormalization._build for the rationale.
- monkeypatch.setattr(sys, "argv", ["lightrag-server", *cli_args])
- from lightrag.api.config import parse_args
- from lightrag.api import lightrag_server
- from lightrag.api.lightrag_server import create_app
- # Redirect both check_frontend_build() and the StaticFiles mount to
- # our staged tmp directory.
- monkeypatch.setattr(
- lightrag_server, "__file__", str(tmp_path / "lightrag_server.py")
- )
- args = parse_args()
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- return create_app(args)
- def test_injection_populates_window_config_with_prefix(self, tmp_path, monkeypatch):
- """With api_prefix=/site01, the injected script must carry both the
- api prefix and the composed webui prefix the browser will see."""
- self._stage_index_html(tmp_path)
- app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/site01")
- client = TestClient(app)
- response = client.get("/site01/webui/")
- assert response.status_code == 200
- body = response.text
- # Placeholder must be gone and replaced with the runtime config.
- assert self.PLACEHOLDER not in body
- assert "window.__LIGHTRAG_CONFIG__" in body
- assert '"apiPrefix": "/site01"' in body or '"apiPrefix":"/site01"' in body
- assert (
- '"webuiPrefix": "/site01/webui/"' in body
- or '"webuiPrefix":"/site01/webui/"' in body
- )
- def test_injection_default_prefixes_when_unconfigured(self, tmp_path, monkeypatch):
- """No CLI flags → empty api prefix and the default webui mount.
- The injected JSON must reflect this so the SPA falls through to
- same-origin requests."""
- self._stage_index_html(tmp_path)
- app = self._build_app(tmp_path, monkeypatch)
- client = TestClient(app)
- response = client.get("/webui/")
- assert response.status_code == 200
- body = response.text
- assert '"apiPrefix": ""' in body or '"apiPrefix":""' in body
- assert '"webuiPrefix": "/webui/"' in body or '"webuiPrefix":"/webui/"' in body
- def test_missing_placeholder_serves_original_html(self, tmp_path, monkeypatch):
- """Older builds without the placeholder must still serve cleanly —
- no 500, no partial replacement, just the original HTML. Avoids
- breaking anyone whose pre-built bundle is in use during an upgrade."""
- self._stage_index_html(tmp_path, with_placeholder=False)
- app = self._build_app(tmp_path, monkeypatch)
- client = TestClient(app)
- response = client.get("/webui/")
- assert response.status_code == 200
- # No placeholder was present, so no injected script either.
- assert "window.__LIGHTRAG_CONFIG__" not in response.text
- def test_injection_idempotent_across_requests(self, tmp_path, monkeypatch):
- """Each request reads the file fresh; the placeholder must be
- present in the *file* even after replies (we don't mutate it)."""
- self._stage_index_html(tmp_path)
- app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/abc")
- client = TestClient(app)
- first = client.get("/abc/webui/").text
- second = client.get("/abc/webui/").text
- assert first == second
- # Source file untouched.
- on_disk = (tmp_path / "webui" / "index.html").read_text(encoding="utf-8")
- assert self.PLACEHOLDER in on_disk
- def test_html_response_keeps_no_cache_headers(self, tmp_path, monkeypatch):
- """Injection must not regress the existing no-cache behaviour for
- HTML — otherwise an updated runtime config could be cached client-
- side and never picked up."""
- self._stage_index_html(tmp_path)
- app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/x")
- client = TestClient(app)
- response = client.get("/x/webui/")
- assert response.status_code == 200
- cache_control = response.headers.get("cache-control", "")
- assert "no-cache" in cache_control
- assert "no-store" in cache_control
- class TestUvicornRootPathSemantics:
- """Lock in the deployment contract that both proxy-strip and verbatim
- forwarding work through FastAPI's app-level ``root_path`` plus a
- ``_RootPathNormalizationMiddleware`` — without ever passing
- ``root_path`` through to uvicorn/gunicorn.
- Background:
- - uvicorn builds ``scope["path"] = uvicorn.root_path + <incoming http
- path>`` and ``scope["root_path"] = uvicorn.root_path``
- (uvicorn/protocols/http/h11_impl.py).
- - FastAPI's ``__call__`` overrides ``scope["root_path"]`` from
- ``app.root_path`` but does NOT touch ``scope["path"]``
- (fastapi/applications.py:1131-1134).
- - Starlette's ``Mount.matches`` mutates the child scope's root_path
- to ``original + matched_path`` and again leaves ``scope["path"]``
- untouched (starlette/routing.py:401-432). The inner sub-app
- (e.g. StaticFiles) then sees ``scope["path"]`` and
- ``scope["root_path"]`` that do not overlap, and 404s.
- Concrete failure mode without the middleware (proxy strips /site01,
- backend sees ``/webui/``):
- outer get_route_path: /webui/ does not start with /site01 → /webui/
- Mount.matches: path_regex "^/webui/(?P<path>.*)$" matches
- child scope.root_path = /site01/webui
- child scope.path = /webui/ (unchanged)
- StaticFiles.get_path: /webui/ does not start with /site01/webui →
- returns /webui/ → looked up as filename
- "webui" inside the webui static dir → 404
- ``_RootPathNormalizationMiddleware`` runs before routing and prepends
- ``root_path`` to ``scope["path"]`` whenever the latter does not already
- start with it. This makes both modes converge to the canonical ASGI
- form (path always contains root_path) without requiring uvicorn's
- --root-path — which would break verbatim mode by double-prefixing.
- """
- @staticmethod
- async def _call_with_scope(app, http_path, *, uvicorn_root_path=""):
- """Drive the ASGI app the way uvicorn would.
- Simulates uvicorn's scope construction so we can catch the
- path-doubling bug that TestClient hides.
- """
- full_path = uvicorn_root_path + http_path
- scope = {
- "type": "http",
- "asgi": {"version": "3.0", "spec_version": "2.3"},
- "http_version": "1.1",
- "method": "GET",
- "scheme": "http",
- "server": ("testserver", 80),
- "client": ("testclient", 50000),
- "root_path": uvicorn_root_path,
- "path": full_path,
- "raw_path": full_path.encode("ascii"),
- "query_string": b"",
- "headers": [],
- "state": {},
- }
- status = {"code": None}
- async def receive():
- return {"type": "http.request", "body": b"", "more_body": False}
- async def send(message):
- if message["type"] == "http.response.start":
- status["code"] = message["status"]
- await app(scope, receive, send)
- return status["code"]
- def _build_app_with_prefix(self, prefix):
- original_argv = sys.argv.copy()
- try:
- sys.argv = ["lightrag-server", "--api-prefix", prefix]
- from lightrag.api.config import parse_args
- from lightrag.api.lightrag_server import create_app
- args = parse_args()
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- return create_app(args)
- finally:
- sys.argv = original_argv
- @pytest.mark.asyncio
- async def test_route_strip_mode_matches(self):
- """Plain Route, proxy-strip mode: backend receives /openapi.json."""
- app = self._build_app_with_prefix("/site01")
- status = await self._call_with_scope(app, "/openapi.json")
- assert status == 200
- @pytest.mark.asyncio
- async def test_route_verbatim_mode_matches(self):
- """Plain Route, verbatim mode: backend receives /site01/openapi.json.
- Locks in the contract that PR #3128's original fix broke: setting
- uvicorn root_path would have made this case 404 by doubling the
- prefix in scope["path"].
- """
- app = self._build_app_with_prefix("/site01")
- status = await self._call_with_scope(app, "/site01/openapi.json")
- assert status == 200
- @pytest.mark.asyncio
- async def test_mount_strip_mode_matches(self):
- """WebUI Mount, proxy-strip mode: backend receives /webui/.
- This is the bug the middleware fixes. Without normalization,
- StaticFiles.get_path sees path=/webui/ and root_path=/site01/webui
- (mutated by Mount.matches) and serves the literal "webui" filename
- lookup → 404. With normalization, scope.path becomes
- /site01/webui/ before routing and the lookup resolves to
- index.html.
- """
- app = self._build_app_with_prefix("/site01")
- status = await self._call_with_scope(app, "/webui/")
- assert status == 200
- @pytest.mark.asyncio
- async def test_mount_verbatim_mode_matches(self):
- """WebUI Mount, verbatim mode: backend receives /site01/webui/.
- Already canonical; the middleware is a no-op for this case. The
- test guards against accidentally regressing verbatim while fixing
- strip — symmetric to test_route_verbatim_mode_matches but exercises
- the Mount path with its nested ``get_route_path`` resolution.
- """
- app = self._build_app_with_prefix("/site01")
- status = await self._call_with_scope(app, "/site01/webui/")
- assert status == 200
- @pytest.mark.asyncio
- async def test_simulated_uvicorn_root_path_breaks_verbatim(self):
- """Document the failure mode that the revert prevents.
- If uvicorn's root_path were also "/site01", uvicorn would build
- scope.path = "/site01" + "/site01/openapi.json". Starlette strips
- one prefix; the remaining "/site01/openapi.json" has no route.
- """
- app = self._build_app_with_prefix("/site01")
- status = await self._call_with_scope(
- app, "/site01/openapi.json", uvicorn_root_path="/site01"
- )
- assert status == 404
- def test_launcher_does_not_pass_root_path_to_uvicorn(self, monkeypatch, tmp_path):
- """Guard against re-adding root_path to the uvicorn launcher kwargs.
- Mocks uvicorn.run and exercises lightrag_server.main() far enough
- to capture the config dict, then asserts root_path is absent.
- """
- monkeypatch.setenv("LIGHTRAG_API_PREFIX", "/site01")
- monkeypatch.setattr(sys, "argv", ["lightrag-server"])
- from lightrag.api import lightrag_server
- captured = {}
- def fake_run(**kwargs):
- captured.update(kwargs)
- monkeypatch.setattr(lightrag_server, "uvicorn", MagicMock(run=fake_run))
- monkeypatch.setattr(lightrag_server, "check_env_file", lambda: True)
- monkeypatch.setattr(
- lightrag_server, "check_and_install_dependencies", lambda: None
- )
- monkeypatch.setattr(lightrag_server, "configure_logging", lambda: None)
- monkeypatch.setattr(lightrag_server, "update_uvicorn_mode_config", lambda: None)
- monkeypatch.setattr(lightrag_server, "display_splash_screen", lambda *_: None)
- with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
- mock_rag.return_value = MagicMock()
- # Re-parse args under the patched env so global_args picks up the prefix.
- from lightrag.api.config import parse_args, global_args as _ga
- new_args = parse_args()
- for attr in vars(new_args):
- setattr(_ga, attr, getattr(new_args, attr))
- lightrag_server.main()
- assert "root_path" not in captured, (
- "uvicorn_config must not include root_path; rely on FastAPI's "
- "app-level root_path only — see TestUvicornRootPathSemantics docstring."
- )
- def test_gunicorn_uses_upstream_uvicorn_worker(self):
- """Symmetric guard for the multi-worker launcher.
- gunicorn_config.worker_class must remain the upstream
- ``uvicorn.workers.UvicornWorker`` — a custom subclass injecting
- root_path via CONFIG_KWARGS would re-introduce the same
- path-doubling regression in worker processes.
- """
- from lightrag.api import gunicorn_config
- assert gunicorn_config.worker_class == "uvicorn.workers.UvicornWorker"
|