Skip to content

Commit 664d3fa

Browse files
committed
Merge remote-tracking branch 'upstream/main' into upg_main
2 parents 4526098 + efee474 commit 664d3fa

File tree

3 files changed

+1049
-804
lines changed

3 files changed

+1049
-804
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import pytest
2+
from typing import Self
3+
from simple_logger.logger import get_logger
4+
5+
from tests.model_registry.model_catalog.utils import validate_filter_options_structure
6+
from tests.model_registry.utils import get_rest_headers, execute_get_command
7+
from utilities.user_utils import UserTestSession
8+
9+
LOGGER = get_logger(name=__name__)
10+
11+
pytestmark = [
12+
pytest.mark.usefixtures("updated_dsc_component_state_scope_session", "model_registry_namespace", "original_user")
13+
]
14+
15+
16+
@pytest.mark.parametrize(
17+
"user_token_for_api_calls,",
18+
[
19+
pytest.param(
20+
{},
21+
id="test_filter_options_admin_user",
22+
),
23+
pytest.param(
24+
{"user_type": "test"},
25+
id="test_filter_options_non_admin_user",
26+
),
27+
pytest.param(
28+
{"user_type": "sa_user"},
29+
id="test_filter_options_service_account",
30+
),
31+
],
32+
indirect=["user_token_for_api_calls"],
33+
)
34+
class TestFilterOptionsEndpoint:
35+
"""
36+
Test class for validating the models/filter_options endpoint
37+
RHOAIENG-36696
38+
"""
39+
40+
def test_filter_options_endpoint_validation(
41+
self: Self,
42+
model_catalog_rest_url: list[str],
43+
user_token_for_api_calls: str,
44+
test_idp_user: UserTestSession,
45+
):
46+
"""
47+
Comprehensive test for filter_options endpoint.
48+
Validates all acceptance criteria:
49+
- A GET request returns a 200 OK response
50+
- Response includes filter options for string-based properties with values array containing distinct values
51+
- Response includes filter options for numeric properties with range object containing min/max values
52+
- Core properties are present (license, provider, tasks, validated_on)
53+
"""
54+
url = f"{model_catalog_rest_url[0]}models/filter_options"
55+
LOGGER.info(f"Testing filter_options endpoint: {url}")
56+
57+
# This will raise an exception if the status code is not 200/201 (validates acceptance criteria #1)
58+
response = execute_get_command(
59+
url=url,
60+
headers=get_rest_headers(token=user_token_for_api_calls),
61+
)
62+
63+
assert response is not None, "Filter options response should not be None"
64+
LOGGER.info("Filter options endpoint successfully returned 200 OK")
65+
66+
# Expected core properties based on current API response
67+
expected_properties = {"license", "provider", "tasks", "validated_on"}
68+
69+
# Comprehensive validation using single function (validates acceptance criteria #2, #3, #4)
70+
is_valid, errors = validate_filter_options_structure(response=response, expected_properties=expected_properties)
71+
assert is_valid, f"Filter options validation failed: {'; '.join(errors)}"
72+
73+
filters = response["filters"]
74+
LOGGER.info(f"Found {len(filters)} filter properties: {list(filters.keys())}")
75+
LOGGER.info("All filter options validation passed successfully")
76+
77+
@pytest.mark.skip(reason="TODO: Implement after investigating backend DB queries")
78+
def test_comprehensive_coverage_against_database(
79+
self: Self,
80+
model_catalog_rest_url: list[str],
81+
user_token_for_api_calls: str,
82+
test_idp_user: UserTestSession,
83+
):
84+
"""
85+
STUBBED: Validate filter options are comprehensive across all sources/models in DB.
86+
Acceptance Criteria: The returned options are comprehensive and not limited to a
87+
subset of models or a single source.
88+
89+
TODO IMPLEMENTATION PLAN:
90+
1. Investigate backend endpoint logic:
91+
- Find the source code for /models/filter_options endpoint in kubeflow/model-registry
92+
- Understand what DB tables it queries (likely model/artifact tables)
93+
- Identify the exact SQL queries used to build filter values
94+
- Determine database schema and column names
95+
96+
2. Replicate queries via pod shell:
97+
- Use get_model_catalog_pod() to access catalog pod
98+
- Execute psql commands via pod.execute()
99+
- Query same tables/columns the endpoint uses
100+
- Extract all distinct values for string properties: SELECT DISTINCT license FROM models;
101+
- Extract min/max ranges for numeric properties: SELECT MIN(metric), MAX(metric) FROM models;
102+
103+
3. Compare results:
104+
- API response filter values should match DB query results exactly
105+
- Ensure no values are missing (comprehensive coverage)
106+
- Validate across all sources, not just one
107+
108+
4. DB Access Pattern Example:
109+
catalog_pod = get_model_catalog_pod(client, namespace)[0]
110+
result = catalog_pod.execute(
111+
command=["psql", "-U", "catalog_user", "-d", "catalog_db", "-c", "SELECT DISTINCT license FROM models;"],
112+
container="catalog"
113+
)
114+
115+
5. Implementation considerations:
116+
- Handle different data types (strings vs arrays like tasks)
117+
- Parse psql output correctly
118+
- Handle null/empty values
119+
- Ensure database connection credentials are available
120+
"""
121+
pytest.skip("TODO: Implement comprehensive coverage validation after backend investigation")

tests/model_registry/model_catalog/utils.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Any, Tuple, List
22
import yaml
33

44
from kubernetes.dynamic import DynamicClient
@@ -105,6 +105,130 @@ def _extract_properties_and_required(schema: dict[Any, Any]) -> tuple[set[str],
105105
return all_properties - excluded_fields, required_fields - excluded_fields
106106

107107

108+
def validate_filter_options_structure(
109+
response: dict[Any, Any], expected_properties: set[str] | None = None
110+
) -> Tuple[bool, List[str]]:
111+
"""
112+
Comprehensive validation of filter_options response structure.
113+
114+
Validates:
115+
- Top-level structure (filters object)
116+
- All property types and their required fields
117+
- Core properties presence (if specified)
118+
- String properties: type, values array, distinct values
119+
- Numeric properties: type, range object, min/max validity
120+
121+
Args:
122+
response: The API response to validate
123+
expected_properties: Optional set of core properties that must be present
124+
125+
Returns:
126+
Tuple of (is_valid, list_of_errors)
127+
"""
128+
errors = []
129+
130+
# Validate top-level structure
131+
if not isinstance(response, dict):
132+
errors.append("Response should be a dictionary")
133+
return False, errors
134+
135+
if "filters" not in response:
136+
errors.append("Response should contain 'filters' object")
137+
return False, errors
138+
139+
filters = response["filters"]
140+
if not isinstance(filters, dict):
141+
errors.append("Filters should be a dictionary")
142+
return False, errors
143+
144+
if not filters:
145+
errors.append("Filters object should not be empty")
146+
return False, errors
147+
148+
# Validate expected core properties if specified
149+
if expected_properties:
150+
for prop in expected_properties:
151+
if prop not in filters:
152+
errors.append(f"Core property '{prop}' should be present in filter options")
153+
154+
# Validate each property structure
155+
for prop_name, prop_data in filters.items():
156+
if not isinstance(prop_data, dict):
157+
errors.append(f"Property '{prop_name}' should be a dictionary")
158+
continue
159+
160+
if "type" not in prop_data:
161+
errors.append(f"Property '{prop_name}' should have 'type' field")
162+
continue
163+
164+
prop_type = prop_data["type"]
165+
if not isinstance(prop_type, str) or not prop_type.strip():
166+
errors.append(f"Type for '{prop_name}' should be a non-empty string")
167+
continue
168+
169+
# Validate string properties
170+
if prop_type == "string":
171+
if "values" not in prop_data:
172+
errors.append(f"String property '{prop_name}' should have 'values' array")
173+
continue
174+
175+
values = prop_data["values"]
176+
if not isinstance(values, list):
177+
errors.append(f"Values for '{prop_name}' should be a list")
178+
continue
179+
180+
if not values:
181+
errors.append(f"Values array for '{prop_name}' should not be empty")
182+
continue
183+
184+
# Validate individual values
185+
for i, value in enumerate(values):
186+
if not isinstance(value, str):
187+
errors.append(f"Value at index {i} for '{prop_name}' should be string, got: {type(value)}")
188+
elif not value.strip():
189+
errors.append(f"Value at index {i} for '{prop_name}' should not be empty or whitespace")
190+
191+
# Check for distinct values (no duplicates)
192+
try:
193+
if len(values) != len(set(values)):
194+
errors.append(f"Values for '{prop_name}' should be distinct (found duplicates)")
195+
except TypeError:
196+
errors.append(f"Values for '{prop_name}' should be a list of strings, found unhashable type")
197+
198+
# Validate numeric properties - checking multiple type names since we don't know what the API will return
199+
elif prop_type in ["number", "numeric", "float", "integer", "int"]:
200+
if "range" not in prop_data:
201+
errors.append(f"Numeric property '{prop_name}' should have 'range' object")
202+
continue
203+
204+
range_obj = prop_data["range"]
205+
if not isinstance(range_obj, dict):
206+
errors.append(f"Range for '{prop_name}' should be a dictionary")
207+
continue
208+
209+
# Check min/max presence
210+
if "min" not in range_obj:
211+
errors.append(f"Range for '{prop_name}' should have 'min' value")
212+
if "max" not in range_obj:
213+
errors.append(f"Range for '{prop_name}' should have 'max' value")
214+
215+
if "min" in range_obj and "max" in range_obj:
216+
min_val = range_obj["min"]
217+
max_val = range_obj["max"]
218+
219+
# Validate min/max are numeric
220+
if not isinstance(min_val, (int, float)):
221+
errors.append(f"Min value for '{prop_name}' should be numeric, got: {type(min_val)}")
222+
if not isinstance(max_val, (int, float)):
223+
errors.append(f"Max value for '{prop_name}' should be numeric, got: {type(max_val)}")
224+
225+
# Validate logical relationship (min <= max)
226+
if isinstance(min_val, (int, float)) and isinstance(max_val, (int, float)) and min_val > max_val:
227+
errors.append(f"Min value ({min_val}) should be <= max value ({max_val}) for '{prop_name}'")
228+
229+
return len(errors) == 0, errors
230+
231+
108232
def validate_model_catalog_configmap_data(configmap: ConfigMap, num_catalogs: int) -> None:
109233
"""
110234
Validate the model catalog configmap data.

0 commit comments

Comments
 (0)