From 62d7ad7e9ec8ff6f8239199bc5b69a737cfba49a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:00:22 +0000 Subject: [PATCH] feat(settings): add dynamic autocomplete for AWS/Azure regions, OCR langs, and embedding models Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/settings.py | 36 +++ app/utils/settings_service.py | 17 +- app/utils/suggestion_providers.py | 450 +++++++++++++++++++++++++++++ frontend/templates/settings.html | 144 +++++++++ tests/test_suggestion_providers.py | 299 +++++++++++++++++++ 5 files changed, 939 insertions(+), 7 deletions(-) create mode 100644 app/utils/suggestion_providers.py create mode 100644 tests/test_suggestion_providers.py diff --git a/app/api/settings.py b/app/api/settings.py index 1431b0cf..ffe1dfcf 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -446,6 +446,42 @@ async def install_ocr_languages(request: Request, admin: AdminUser): ) +@router.get("/{key}/suggestions") +async def get_setting_suggestions( + key: str, + request: Request, + q: str = "", + limit: int = 10, +): + """ + Return autocomplete suggestions for a setting key. + + Fetches values dynamically from cloud SDKs, installed tools, or + curated static lists depending on the setting. Results are filtered + by case-insensitive substring match on the ``q`` parameter. + + This endpoint does **not** require admin privileges so that the + autocomplete widget works for any authenticated user viewing settings. + """ + from app.utils.suggestion_providers import SUGGESTION_PROVIDERS, get_suggestions # noqa: PLC0415 + + if key not in SUGGESTION_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No suggestions available for setting '{key}'", + ) + + try: + suggestions = get_suggestions(key, query=q, limit=max(1, min(limit, 50))) + return {"key": key, "suggestions": suggestions} + except Exception as e: + logger.error(f"Error fetching suggestions for {key}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch suggestions", + ) + + @router.get("/{key}/history") async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser): """ diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index eee9701d..c324c84d 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -360,7 +360,7 @@ SETTING_METADATA = { "azure_region": { "category": "AI Services", "description": "Azure region for Document Intelligence services (e.g., eastus)", - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -412,8 +412,11 @@ SETTING_METADATA = { }, "tesseract_language": { "category": "OCR Engines", - "description": "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. Default: eng+deu (English + German).", - "type": "string", + "description": ( + "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. " + "Combine multiple with '+'. Default: eng+deu (English + German)." + ), + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -421,8 +424,8 @@ SETTING_METADATA = { # OCR – EasyOCR "easyocr_languages": { "category": "OCR Engines", - "description": "Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German).", - "type": "string", + "description": ("Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German)."), + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -853,7 +856,7 @@ SETTING_METADATA = { "aws_region": { "category": "Storage Providers", "description": "AWS region for S3 bucket (default: us-east-1)", - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -1476,7 +1479,7 @@ SETTING_METADATA = { "Model name used for generating text embeddings via the OpenAI-compatible API. " "Embeddings drive the document similarity feature. Default: text-embedding-3-small." ), - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, diff --git a/app/utils/suggestion_providers.py b/app/utils/suggestion_providers.py new file mode 100644 index 00000000..68c6fb70 --- /dev/null +++ b/app/utils/suggestion_providers.py @@ -0,0 +1,450 @@ +""" +Dynamic suggestion providers for autocomplete-enabled settings. + +Each provider function returns a list of strings that are valid values +for a particular setting. Providers try to resolve values dynamically +(e.g. by querying cloud SDKs or scanning installed software) and fall +back to curated static lists when the runtime environment lacks the +required libraries, credentials, or connectivity. + +**Fallback guarantee**: Every provider wraps its dynamic resolution in a +``try/except Exception`` so that it *always* returns a usable list. +Missing libraries (``ImportError``), missing credentials, network +failures, or unexpected SDK errors all trigger a graceful fallback to +the bundled static list. +""" + +import logging +import subprocess # noqa: S404 — only used with fixed args, no user input + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# AWS regions — fetched from boto3 if available +# --------------------------------------------------------------------------- + +_AWS_REGIONS_STATIC: list[str] = [ + "af-south-1", + "ap-east-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", +] + + +def get_aws_regions() -> list[str]: + """Return available AWS S3 regions via boto3, falling back to a static list.""" + try: + import boto3 # noqa: PLC0415 + + session = boto3.session.Session() + regions = sorted(session.get_available_regions("s3")) + if regions: + return regions + except Exception: + logger.debug("boto3 not available or failed; using static AWS region list") + return _AWS_REGIONS_STATIC + + +# --------------------------------------------------------------------------- +# Azure regions — resolved from known Cognitive Services locations +# --------------------------------------------------------------------------- + +_AZURE_REGIONS_STATIC: list[str] = [ + "australiacentral", + "australiaeast", + "australiasoutheast", + "brazilsouth", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "eastasia", + "eastus", + "eastus2", + "francecentral", + "germanywestcentral", + "japaneast", + "japanwest", + "koreacentral", + "koreasouth", + "northcentralus", + "northeurope", + "norwayeast", + "polandcentral", + "qatarcentral", + "southafricanorth", + "southcentralus", + "southeastasia", + "swedencentral", + "switzerlandnorth", + "uaenorth", + "uksouth", + "ukwest", + "westcentralus", + "westeurope", + "westus", + "westus2", + "westus3", +] + + +def get_azure_regions() -> list[str]: + """Return Azure Cognitive Services regions. + + Falls back to a curated static list because there is no + unauthenticated public endpoint to enumerate regions. + """ + return _AZURE_REGIONS_STATIC + + +# --------------------------------------------------------------------------- +# Tesseract languages — probed from `tesseract --list-langs` +# --------------------------------------------------------------------------- + +_TESSERACT_LANGS_STATIC: list[str] = [ + "afr", + "amh", + "ara", + "asm", + "aze", + "bel", + "ben", + "bod", + "bos", + "bre", + "bul", + "cat", + "ceb", + "ces", + "chi_sim", + "chi_tra", + "chr", + "cos", + "cym", + "dan", + "deu", + "div", + "ell", + "eng", + "enm", + "epo", + "est", + "eus", + "fao", + "fas", + "fil", + "fin", + "fra", + "frk", + "frm", + "fry", + "gla", + "gle", + "glg", + "grc", + "guj", + "hat", + "heb", + "hin", + "hrv", + "hun", + "hye", + "iku", + "ind", + "isl", + "ita", + "jav", + "jpn", + "kan", + "kat", + "kaz", + "khm", + "kir", + "kor", + "lao", + "lat", + "lav", + "lit", + "ltz", + "mal", + "mar", + "mkd", + "mlt", + "mon", + "mri", + "msa", + "mya", + "nep", + "nld", + "nor", + "oci", + "ori", + "pan", + "pol", + "por", + "pus", + "que", + "ron", + "rus", + "san", + "sin", + "slk", + "slv", + "snd", + "spa", + "sqi", + "srp", + "sun", + "swa", + "swe", + "syr", + "tam", + "tat", + "tel", + "tgk", + "tha", + "tir", + "ton", + "tur", + "uig", + "ukr", + "urd", + "uzb", + "vie", + "yid", + "yor", +] + + +def get_tesseract_languages() -> list[str]: + """Return installed Tesseract language codes, falling back to a static list.""" + try: + result = subprocess.run( # noqa: S603, S607 + ["tesseract", "--list-langs"], # noqa: S607 + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0: + lines = result.stdout.strip().splitlines() + # First line is the header ("List of available languages ...") + langs = sorted(line.strip() for line in lines[1:] if line.strip()) + if langs: + return langs + except Exception: + logger.debug("tesseract not available; using static language list") + return _TESSERACT_LANGS_STATIC + + +# --------------------------------------------------------------------------- +# EasyOCR languages — probed from the easyocr module +# --------------------------------------------------------------------------- + +_EASYOCR_LANGS_STATIC: list[str] = [ + "abq", + "ady", + "af", + "ang", + "ar", + "as", + "ava", + "az", + "be", + "bg", + "bh", + "bn", + "bs", + "ch_sim", + "ch_tra", + "che", + "cs", + "cy", + "da", + "dar", + "de", + "en", + "es", + "et", + "fa", + "fi", + "fr", + "ga", + "gom", + "hi", + "hr", + "hu", + "id", + "inh", + "is", + "it", + "ja", + "ka", + "kk", + "km", + "kn", + "ko", + "ku", + "la", + "lbe", + "lez", + "lt", + "lv", + "mah", + "mai", + "mi", + "mn", + "mr", + "ms", + "mt", + "ne", + "new", + "nl", + "no", + "oc", + "pi", + "pl", + "pt", + "ro", + "ru", + "rs_cyrillic", + "rs_latin", + "sa", + "sck", + "sk", + "sl", + "sq", + "sv", + "sw", + "ta", + "tab", + "te", + "th", + "tjk", + "tl", + "tr", + "ug", + "uk", + "ur", + "uz", + "vi", +] + + +def get_easyocr_languages() -> list[str]: + """Return supported EasyOCR language codes, falling back to a static list.""" + try: + import easyocr # noqa: PLC0415 + + # easyocr stores the language list internally + if hasattr(easyocr, "config") and hasattr(easyocr.config, "all_lang_list"): + return sorted(easyocr.config.all_lang_list) + except Exception: + logger.debug("easyocr not available; using static language list") + return _EASYOCR_LANGS_STATIC + + +# --------------------------------------------------------------------------- +# Embedding models — static list (no standard discovery API) +# --------------------------------------------------------------------------- + +_EMBEDDING_MODELS: list[str] = [ + "text-embedding-3-small", + "text-embedding-3-large", + "text-embedding-ada-002", + "nomic-embed-text", + "nomic-embed-text-v1.5", + "mxbai-embed-large", + "mxbai-embed-large-v1", + "all-MiniLM-L6-v2", + "all-MiniLM-L12-v2", + "bge-small-en-v1.5", + "bge-base-en-v1.5", + "bge-large-en-v1.5", + "e5-small-v2", + "e5-base-v2", + "e5-large-v2", + "gte-small", + "gte-base", + "gte-large", + "voyage-3", + "voyage-3-lite", + "voyage-code-3", +] + + +def get_embedding_models() -> list[str]: + """Return known embedding model names.""" + return _EMBEDDING_MODELS + + +# --------------------------------------------------------------------------- +# Registry — maps setting keys to their provider functions +# --------------------------------------------------------------------------- + +SUGGESTION_PROVIDERS: dict[str, callable] = { + "aws_region": get_aws_regions, + "azure_region": get_azure_regions, + "tesseract_language": get_tesseract_languages, + "easyocr_languages": get_easyocr_languages, + "embedding_model": get_embedding_models, +} + + +def get_suggestions(key: str, query: str = "", limit: int = 10) -> list[str]: + """ + Return autocomplete suggestions for the given setting key. + + Fetches the full list from the registered provider, filters by + case-insensitive substring match on *query*, and returns at most + *limit* results. + + Args: + key: The setting key (must be registered in SUGGESTION_PROVIDERS). + query: Substring to filter by (case-insensitive). + limit: Maximum number of results to return. + + Returns: + Filtered list of suggestion strings. + + Raises: + KeyError: If no provider is registered for *key*. + """ + provider = SUGGESTION_PROVIDERS.get(key) + if provider is None: + raise KeyError(f"No suggestion provider registered for setting '{key}'") + + all_values = provider() + q = query.strip().lower() + + if q: + filtered = [v for v in all_values if q in v.lower()] + else: + filtered = list(all_values) + + return filtered[:limit] diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index 61702cf3..523e4983 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -365,6 +365,80 @@ Type to search existing users by name, or enter any identifier manually.

+ {% elif setting.metadata.type == 'autocomplete' %} + +
+
+ + +
+ +
+ +
+ +
+ + + +
+ No matches — you can still type a custom value +
+

+ + Type to search known values, or enter any custom value. +

+
{% elif setting.metadata.type == 'model_picker' %}
@@ -859,5 +933,75 @@ function userAutocomplete(settingKey) { } }; } + +/** + * Alpine.js component for the generic dynamic autocomplete setting widget. + * Fetches suggestions from GET /api/settings/{key}/suggestions?q=&limit=10 + * using a debounced API call. Implements the ARIA 1.1 combobox pattern. + */ +function dynamicAutocomplete(settingKey) { + return { + filtered: [], + showSuggestions: false, + loading: false, + searchDone: false, + highlightedIdx: -1, + statusText: '', + + async fetchSuggestions(query) { + this.loading = true; + this.searchDone = false; + this.highlightedIdx = -1; + this.statusText = 'Searching…'; + try { + const resp = await fetch('/api/settings/' + encodeURIComponent(settingKey) + '/suggestions?q=' + encodeURIComponent(query || '') + '&limit=10'); + if (resp.ok) { + const data = await resp.json(); + this.filtered = data.suggestions || []; + } else { + this.filtered = []; + } + } catch { + this.filtered = []; + } + this.loading = false; + this.searchDone = true; + this.showSuggestions = true; + if (this.filtered.length === 0) { + this.statusText = 'No matches found'; + } else if (this.filtered.length === 1) { + this.statusText = '1 suggestion found'; + } else { + this.statusText = this.filtered.length + ' suggestions found'; + } + }, + + selectItem(item) { + this.formData[settingKey] = item; + this.showSuggestions = false; + this.statusText = item + ' selected'; + }, + + highlightNext() { + if (this.filtered.length === 0) return; + this.highlightedIdx = (this.highlightedIdx + 1) % this.filtered.length; + this.statusText = this.filtered[this.highlightedIdx]; + }, + + highlightPrev() { + if (this.filtered.length === 0) return; + this.highlightedIdx = this.highlightedIdx <= 0 ? this.filtered.length - 1 : this.highlightedIdx - 1; + this.statusText = this.filtered[this.highlightedIdx]; + }, + + selectHighlighted() { + if (this.highlightedIdx >= 0 && this.highlightedIdx < this.filtered.length) { + this.selectItem(this.filtered[this.highlightedIdx]); + } else { + this.showSuggestions = false; + } + } + }; +} {% endblock %} diff --git a/tests/test_suggestion_providers.py b/tests/test_suggestion_providers.py new file mode 100644 index 00000000..cd9c585d --- /dev/null +++ b/tests/test_suggestion_providers.py @@ -0,0 +1,299 @@ +""" +Tests for the suggestion providers and the settings suggestions API. + +Covers: +- Dynamic suggestion providers (AWS, Azure, Tesseract, EasyOCR, embedding models) +- GET /api/settings/{key}/suggestions endpoint +- Substring filtering and limit enforcement +- Fallback to static lists when SDKs are unavailable +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.utils.suggestion_providers import ( + _AZURE_REGIONS_STATIC, + _EASYOCR_LANGS_STATIC, + _EMBEDDING_MODELS, + _TESSERACT_LANGS_STATIC, + SUGGESTION_PROVIDERS, + get_aws_regions, + get_azure_regions, + get_easyocr_languages, + get_embedding_models, + get_suggestions, + get_tesseract_languages, +) + +# --------------------------------------------------------------------------- +# Provider unit tests +# --------------------------------------------------------------------------- + + +class TestAWSRegionProvider: + """Tests for get_aws_regions.""" + + @pytest.mark.unit + def test_returns_list(self): + """Should return a non-empty list of region strings.""" + regions = get_aws_regions() + assert isinstance(regions, list) + assert len(regions) > 0 + assert all(isinstance(r, str) for r in regions) + + @pytest.mark.unit + def test_us_east_1_present(self): + """us-east-1 should always be in the list.""" + regions = get_aws_regions() + assert "us-east-1" in regions + + @pytest.mark.unit + def test_results_are_sorted(self): + """Region list should be sorted alphabetically.""" + regions = get_aws_regions() + assert regions == sorted(regions) + + @pytest.mark.unit + def test_fallback_on_boto3_failure(self): + """Should fall back to static list when boto3 raises.""" + with patch.dict("sys.modules", {"boto3": None}): + regions = get_aws_regions() + # Should still return a list (the static fallback) + assert isinstance(regions, list) + assert "us-east-1" in regions + + +class TestAzureRegionProvider: + """Tests for get_azure_regions.""" + + @pytest.mark.unit + def test_returns_static_list(self): + """Should return the curated static list.""" + regions = get_azure_regions() + assert regions == _AZURE_REGIONS_STATIC + assert "eastus" in regions + + @pytest.mark.unit + def test_contains_common_regions(self): + """Common Azure regions should be present.""" + regions = get_azure_regions() + for region in ["eastus", "westeurope", "uksouth", "japaneast"]: + assert region in regions + + +class TestTesseractLanguageProvider: + """Tests for get_tesseract_languages.""" + + @pytest.mark.unit + def test_returns_list(self): + """Should return a non-empty list.""" + langs = get_tesseract_languages() + assert isinstance(langs, list) + assert len(langs) > 0 + + @pytest.mark.unit + def test_eng_present(self): + """English should always be available.""" + langs = get_tesseract_languages() + assert "eng" in langs + + @pytest.mark.unit + def test_fallback_on_missing_tesseract(self): + """Should fall back to static list when tesseract is not installed.""" + with patch("subprocess.run", side_effect=FileNotFoundError): + langs = get_tesseract_languages() + assert langs == _TESSERACT_LANGS_STATIC + + @pytest.mark.unit + def test_uses_subprocess_output_when_available(self): + """Should parse subprocess output when tesseract is installed.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "List of available languages (4):\neng\ndeu\nfra\nita\n" + + with patch("subprocess.run", return_value=mock_result): + langs = get_tesseract_languages() + + assert langs == ["deu", "eng", "fra", "ita"] + + +class TestEasyOCRLanguageProvider: + """Tests for get_easyocr_languages.""" + + @pytest.mark.unit + def test_returns_list(self): + """Should return a non-empty list.""" + langs = get_easyocr_languages() + assert isinstance(langs, list) + assert len(langs) > 0 + + @pytest.mark.unit + def test_en_present(self): + """English should always be available.""" + langs = get_easyocr_languages() + assert "en" in langs + + @pytest.mark.unit + def test_fallback_when_easyocr_missing(self): + """Should fall back to static list when easyocr is not installed.""" + # easyocr is not installed in the test env, so this tests the real fallback + langs = get_easyocr_languages() + assert langs == _EASYOCR_LANGS_STATIC + + +class TestEmbeddingModelProvider: + """Tests for get_embedding_models.""" + + @pytest.mark.unit + def test_returns_list(self): + """Should return a non-empty list.""" + models = get_embedding_models() + assert isinstance(models, list) + assert len(models) > 0 + + @pytest.mark.unit + def test_default_model_present(self): + """The default model should be in the list.""" + models = get_embedding_models() + assert "text-embedding-3-small" in models + + @pytest.mark.unit + def test_returns_static_list(self): + """Should return the static embedding model list.""" + assert get_embedding_models() == _EMBEDDING_MODELS + + +# --------------------------------------------------------------------------- +# get_suggestions() tests +# --------------------------------------------------------------------------- + + +class TestGetSuggestions: + """Tests for the get_suggestions aggregator function.""" + + @pytest.mark.unit + def test_all_providers_registered(self): + """All expected keys should be registered.""" + expected_keys = {"aws_region", "azure_region", "tesseract_language", "easyocr_languages", "embedding_model"} + assert expected_keys == set(SUGGESTION_PROVIDERS.keys()) + + @pytest.mark.unit + def test_unregistered_key_raises(self): + """Requesting suggestions for an unknown key raises KeyError.""" + with pytest.raises(KeyError, match="no_such_setting"): + get_suggestions("no_such_setting") + + @pytest.mark.unit + def test_empty_query_returns_all(self): + """Empty query returns all suggestions up to the limit.""" + results = get_suggestions("embedding_model", query="", limit=100) + assert len(results) == len(_EMBEDDING_MODELS) + + @pytest.mark.unit + def test_substring_filtering(self): + """Query filters by case-insensitive substring.""" + results = get_suggestions("aws_region", query="east", limit=50) + assert all("east" in r.lower() for r in results) + assert len(results) > 0 + + @pytest.mark.unit + def test_case_insensitive(self): + """Filtering should be case-insensitive.""" + results = get_suggestions("aws_region", query="EAST", limit=50) + assert len(results) > 0 + assert "us-east-1" in results + + @pytest.mark.unit + def test_limit_respected(self): + """Results should not exceed the limit.""" + results = get_suggestions("tesseract_language", query="", limit=3) + assert len(results) == 3 + + @pytest.mark.unit + def test_whitespace_trimmed(self): + """Leading/trailing whitespace in the query should be trimmed.""" + results = get_suggestions("aws_region", query=" us-east ", limit=10) + assert all("us-east" in r.lower() for r in results) + + +# --------------------------------------------------------------------------- +# API endpoint integration tests +# --------------------------------------------------------------------------- + + +class TestSuggestionsEndpoint: + """Tests for GET /api/settings/{key}/suggestions.""" + + @pytest.mark.integration + def test_aws_region_suggestions(self, client): + """AWS region endpoint returns suggestions.""" + response = client.get("/api/settings/aws_region/suggestions?q=east") + assert response.status_code == 200 + data = response.json() + assert "suggestions" in data + assert data["key"] == "aws_region" + assert len(data["suggestions"]) > 0 + assert all("east" in s.lower() for s in data["suggestions"]) + + @pytest.mark.integration + def test_azure_region_suggestions(self, client): + """Azure region endpoint returns suggestions.""" + response = client.get("/api/settings/azure_region/suggestions?q=europe") + assert response.status_code == 200 + data = response.json() + assert "westeurope" in data["suggestions"] + + @pytest.mark.integration + def test_tesseract_language_suggestions(self, client): + """Tesseract language endpoint returns suggestions.""" + response = client.get("/api/settings/tesseract_language/suggestions?q=eng") + assert response.status_code == 200 + data = response.json() + assert "eng" in data["suggestions"] + + @pytest.mark.integration + def test_easyocr_language_suggestions(self, client): + """EasyOCR language endpoint returns suggestions.""" + response = client.get("/api/settings/easyocr_languages/suggestions?q=de") + assert response.status_code == 200 + data = response.json() + assert "de" in data["suggestions"] + + @pytest.mark.integration + def test_embedding_model_suggestions(self, client): + """Embedding model endpoint returns suggestions.""" + response = client.get("/api/settings/embedding_model/suggestions?q=embed") + assert response.status_code == 200 + data = response.json() + assert len(data["suggestions"]) > 0 + + @pytest.mark.integration + def test_unknown_key_returns_404(self, client): + """Unknown setting key returns 404.""" + response = client.get("/api/settings/nonexistent_setting/suggestions") + assert response.status_code == 404 + + @pytest.mark.integration + def test_empty_query_returns_results(self, client): + """Empty query returns all suggestions up to limit.""" + response = client.get("/api/settings/embedding_model/suggestions?q=") + assert response.status_code == 200 + data = response.json() + assert len(data["suggestions"]) > 0 + + @pytest.mark.integration + def test_limit_parameter(self, client): + """Limit parameter restricts the number of results.""" + response = client.get("/api/settings/aws_region/suggestions?q=&limit=3") + assert response.status_code == 200 + data = response.json() + assert len(data["suggestions"]) <= 3 + + @pytest.mark.integration + def test_limit_clamped_to_max(self, client): + """Limit over 50 should be clamped to 50.""" + response = client.get("/api/settings/tesseract_language/suggestions?q=&limit=999") + assert response.status_code == 200 + data = response.json() + assert len(data["suggestions"]) <= 50