feat(settings): add dynamic autocomplete for AWS/Azure regions, OCR langs, and embedding models

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-05 22:00:22 +00:00
parent 3fd2bb3c5f
commit 62d7ad7e9e
5 changed files with 939 additions and 7 deletions
+36
View File
@@ -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):
"""
+10 -7
View File
@@ -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,
+450
View File
@@ -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]
+144
View File
@@ -365,6 +365,80 @@
Type to search existing users by name, or enter any identifier manually.
</p>
</div>
{% elif setting.metadata.type == 'autocomplete' %}
<!-- Dynamic Autocomplete (WCAG 2.1 AA combobox pattern) -->
<div class="relative" x-data="dynamicAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
<div class="relative">
<label for="{{ setting.key }}" class="sr-only">{{ setting.key.replace('_', ' ').title() }}</label>
<input
type="text"
id="{{ setting.key }}"
name="{{ setting.key }}"
x-model="formData['{{ setting.key }}']"
@input.debounce.200ms="fetchSuggestions($event.target.value)"
@focus="fetchSuggestions(formData['{{ setting.key }}'] || '')"
@keydown.arrow-down.prevent="highlightNext()"
@keydown.arrow-up.prevent="highlightPrev()"
@keydown.enter.prevent="selectHighlighted()"
@keydown.escape="showSuggestions = false"
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Type to search or enter a value…"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-label="{{ setting.key.replace('_', ' ').title() }} — type to search suggestions"
:aria-expanded="showSuggestions && filtered.length > 0"
aria-haspopup="listbox"
:aria-controls="'{{ setting.key }}_listbox'"
:aria-activedescendant="highlightedIdx >= 0 ? '{{ setting.key }}_opt_' + highlightedIdx : ''"
/>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-search text-gray-400 text-sm" aria-hidden="true"></i>
</div>
<div x-show="loading" class="absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<i class="fas fa-spinner fa-spin text-gray-400 text-sm"></i>
</div>
</div>
<!-- Live region for screen reader announcements -->
<div class="sr-only" aria-live="polite" aria-atomic="true" x-text="statusText"></div>
<!-- Suggestion dropdown -->
<ul
x-show="showSuggestions && filtered.length > 0"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
role="listbox"
:id="'{{ setting.key }}_listbox'"
:aria-label="'{{ setting.key.replace('_', ' ').title() }} suggestions'"
>
<template x-for="(item, idx) in filtered" :key="item">
<li
:id="'{{ setting.key }}_opt_' + idx"
role="option"
:aria-selected="highlightedIdx === idx"
@mouseenter="highlightedIdx = idx"
@click="selectItem(item)"
class="px-3 py-2.5 text-sm cursor-pointer min-h-[44px] flex items-center"
:class="highlightedIdx === idx ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'"
>
<span x-text="item"></span>
</li>
</template>
</ul>
<!-- Empty state -->
<div
x-show="showSuggestions && filtered.length === 0 && !loading && searchDone"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
role="status"
>
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matches — you can still type a custom value
</div>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Type to search known values, or enter any custom value.
</p>
</div>
{% elif setting.metadata.type == 'model_picker' %}
<!-- Model Picker -->
<div class="relative">
@@ -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=<term>&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;
}
}
};
}
</script>
{% endblock %}
+299
View File
@@ -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