Files
DocsGPT/tests/core/test_settings.py
T
arc53-machine a47a2c34ec docs(settings): render field constraints as code spans in the reference
The docs site failed to build: a bare "<= 1" in the prose of the
generated page is parsed by MDX as the start of a JSX tag ("Unexpected
character '=' before name"). Constraints are rendered as code spans now,
where MDX leaves them alone, and a test rejects any bare <, { or } outside
a code span so a future description cannot reintroduce the failure.
Verified with a local next build of the docs site.
2026-09-17 13:05:01 +01:00

241 lines
9.8 KiB
Python

"""Contract tests for ``docsgpt.core.settings``.
``Settings`` is composed from one ``SettingsGroup`` per domain; these tests pin
the properties that composition must keep (flat names, no duplicate fields,
validators from every group applied) and that the generated reference page
tracks the definitions.
"""
import types
import typing
import warnings
from pathlib import Path
import pytest
from pydantic import ValidationError
from docsgpt.core.settings import SETTINGS_GROUPS, Settings, settings
from docsgpt.core.settings.reference import reference_path, render_reference
SECRET_FIELDS = (
"API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GROQ_API_KEY",
"HUGGINGFACE_API_KEY",
"NOVITA_API_KEY",
"OPEN_ROUTER_API_KEY",
"EMBEDDINGS_KEY",
"FALLBACK_LLM_API_KEY",
"QDRANT_API_KEY",
"ELASTIC_PASSWORD",
"ELEVENLABS_API_KEY",
"INTERNAL_KEY",
"SCIM_TOKEN",
"OIDC_ISSUER",
"GITHUB_ACCESS_TOKEN",
"MICROSOFT_AUTHORITY",
"MCP_OAUTH_REDIRECT_URI",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"SANDBOX_GATEWAY_AUTH_TOKEN",
"DAYTONA_API_KEY",
)
@pytest.mark.unit
class TestComposition:
def test_every_group_field_is_a_flat_settings_attribute(self):
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning) # reading a deprecated field warns
for _, group in SETTINGS_GROUPS:
for name in group.model_fields:
assert name in Settings.model_fields, name
assert hasattr(settings, name), name
def test_deprecated_fields_warn_on_read(self):
with pytest.warns(DeprecationWarning, match="S3_REGION"):
_ = Settings(_env_file=None).SAGEMAKER_REGION
def test_no_field_is_defined_in_two_groups(self):
owners: dict[str, str] = {}
for title, group in SETTINGS_GROUPS:
for name in group.model_fields:
assert name not in owners, f"{name} is defined in both {owners[name]} and {title}"
owners[name] = title
assert len(owners) == len(Settings.model_fields)
def test_every_field_has_a_description(self):
missing = [name for name, field in Settings.model_fields.items() if not field.description]
assert not missing, f"settings without a description: {missing}"
def test_defaults_load_without_an_env_file(self, monkeypatch):
for name in Settings.model_fields:
monkeypatch.delenv(name, raising=False)
fresh = Settings(_env_file=None)
assert fresh.LLM_PROVIDER == "docsgpt"
assert fresh.VECTOR_STORE == "faiss"
@pytest.mark.unit
class TestValidators:
"""Validators live on the group that owns the field; composition must keep all of them.
Pydantic collects validators by method name across the MRO, so two groups
defining one under the same name would silently keep only one; the checks
below span every group.
"""
def test_every_optional_string_treats_unset_spellings_as_none(self):
names = [
name
for name, field in Settings.model_fields.items()
if typing.get_origin(field.annotation) in (typing.Union, types.UnionType)
and type(None) in typing.get_args(field.annotation)
and all(a is str or typing.get_origin(a) is typing.Literal for a in typing.get_args(field.annotation) if a is not type(None))
]
assert len(names) > 60
assert {"EMBEDDINGS_POOLING", "AUTH_TYPE", "OIDC_ISSUER"} <= set(names)
loaded = Settings.model_validate({name: " None " for name in names})
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
assert [name for name in names if getattr(loaded, name) is not None] == []
def test_plain_strings_keep_empty_values(self):
assert Settings.model_validate({"MILVUS_TOKEN": "", "JWT_SECRET_KEY": ""}).MILVUS_TOKEN == ""
@pytest.mark.parametrize("name", SECRET_FIELDS)
@pytest.mark.parametrize("raw", ["None", "none", "", " "])
def test_unset_secret_spellings_become_none(self, name, raw):
assert getattr(Settings.model_validate({name: raw}), name) is None
@pytest.mark.parametrize("name", SECRET_FIELDS)
def test_secret_is_stripped(self, name):
assert getattr(Settings.model_validate({name: " k3y "}), name) == "k3y"
def test_normalize_api_key_classmethod_is_kept(self):
assert Settings.normalize_api_key("None") is None
assert Settings.normalize_api_key(" x ") == "x"
assert Settings.normalize_api_key(42) == 42
def test_postgres_uris_are_normalized(self):
loaded = Settings.model_validate(
{"POSTGRES_URI": "postgres://u:p@h/db", "PGVECTOR_CONNECTION_STRING": "postgresql+psycopg://u:p@h/v"}
)
assert loaded.POSTGRES_URI.startswith("postgresql+psycopg://")
assert loaded.PGVECTOR_CONNECTION_STRING.startswith("postgresql://")
def test_legacy_docling_ocr_aliases_are_read(self):
loaded = Settings.model_validate({"DOCLING_OCR_ENABLED": "true", "DOCLING_OCR_MIN_CHARS_PER_PAGE": "7"})
assert loaded.OCR_ENABLED is True
assert loaded.OCR_MIN_CHARS_PER_PAGE == 7
@pytest.mark.unit
class TestReference:
def test_reference_lists_every_setting_once(self):
page = render_reference()
for name in Settings.model_fields:
assert page.count(f"### `{name}`") == 1, name
def test_reference_prose_has_no_bare_angle_brackets_or_braces(self):
"""MDX parses ``<`` and ``{`` in prose as JSX; only code spans may carry them raw."""
for lineno, line in enumerate(render_reference().splitlines(), 1):
if line.startswith(("{/*", "---")):
continue
prose = "".join(line.split("`")[::2]) # drop the inside of every code span
prose = prose.replace("\\{", "").replace("\\}", "") # escaped braces are fine
assert "<" not in prose and "{" not in prose and "}" not in prose, f"line {lineno}: {line}"
def test_checked_in_reference_is_current(self):
path: Path = reference_path()
if not path.exists():
pytest.skip("docs tree not present (installed package, not a checkout)")
assert path.read_text(encoding="utf-8") == render_reference(), (
"docs/content/Deploying/Settings-Reference.mdx is stale; "
"run: python -m docsgpt.core.settings.reference --write"
)
@pytest.mark.unit
class TestCrossFieldRules:
OIDC = {"OIDC_ISSUER": "https://idp.example/", "OIDC_CLIENT_ID": "docsgpt", "OIDC_FRONTEND_URL": "http://app"}
def test_oidc_requires_issuer_client_and_frontend(self):
with pytest.raises(ValidationError, match="AUTH_TYPE=oidc requires settings: OIDC_CLIENT_ID, OIDC_FRONTEND_URL"):
Settings.model_validate({"AUTH_TYPE": "oidc", "OIDC_ISSUER": self.OIDC["OIDC_ISSUER"]})
@pytest.mark.parametrize("raw", ["", "None", " "])
def test_oidc_unset_spellings_do_not_satisfy_the_requirement(self, raw):
with pytest.raises(ValidationError, match="OIDC_CLIENT_ID"):
Settings.model_validate({"AUTH_TYPE": "oidc", **self.OIDC, "OIDC_CLIENT_ID": raw})
def test_oidc_with_required_settings_loads(self):
assert Settings.model_validate({"AUTH_TYPE": "OIDC", **self.OIDC}).AUTH_TYPE == "oidc"
def test_oidc_settings_are_not_required_for_other_modes(self):
assert Settings.model_validate({"AUTH_TYPE": "session_jwt"}).OIDC_ISSUER is None
@pytest.mark.unit
class TestClosedChoices:
"""Enum-like settings are Literal types: a typo fails at startup instead of falling through."""
@pytest.mark.parametrize("name", ["AUTH_TYPE", "EMBEDDINGS_POOLING"])
@pytest.mark.parametrize("raw", ["None", "none", "", " "])
def test_optional_choice_unset_spellings(self, name, raw):
assert getattr(Settings.model_validate({name: raw}), name) is None
@pytest.mark.parametrize(
("name", "raw", "expected"),
[
("AUTH_TYPE", " Session_JWT ", "session_jwt"),
("VECTOR_STORE", "PGVector", "pgvector"),
("STORAGE_TYPE", "S3", "s3"),
("URL_STRATEGY", "Backend", "backend"),
("OCR_BACKEND", "Native", "native"),
("OCR_ENGINE", "Tesseract ", "tesseract"),
("SANDBOX_BACKEND", "Daytona", "daytona"),
("DOC_PARSER_ENGINE", "Docling", "docling"),
("TTS_PROVIDER", "ElevenLabs", "elevenlabs"),
("STT_PROVIDER", "", "none"),
("TTS_PROVIDER", "NONE", "none"),
("EMBEDDINGS_POOLING", "CLS", "cls"),
],
)
def test_choices_are_case_insensitive(self, name, raw, expected):
assert getattr(Settings.model_validate({name: raw}), name) == expected
@pytest.mark.parametrize(
("name", "raw"),
[
("AUTH_TYPE", "basic"),
("VECTOR_STORE", "lancedb"),
("STORAGE_TYPE", "gcs"),
("OCR_BACKEND", "paddle"),
("SANDBOX_BACKEND", "docker"),
("DOC_PARSER_ENGINE", "fast"),
("STT_PROVIDER", "whisper"),
("EMBEDDINGS_POOLING", "max"),
],
)
def test_unknown_choice_is_rejected(self, name, raw):
with pytest.raises(ValidationError):
Settings.model_validate({name: raw})
@pytest.mark.parametrize(
("name", "raw"),
[
("EMBEDDINGS_BATCH_SIZE", 0),
("COMPRESSION_THRESHOLD_PERCENTAGE", 1.5),
("UPLOAD_MAX_FILE_BYTES", 0),
("MESSAGE_EVENTS_RETENTION_DAYS", 0),
("REMOTE_DEVICE_CMD_QUEUE_TTL_SECONDS", 605),
("GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION", -1),
],
)
def test_out_of_range_numbers_are_rejected(self, name, raw):
with pytest.raises(ValidationError):
Settings.model_validate({name: raw})