- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/ - Extract shared stdlib-only utilities to shared/src/ss_tools/shared/ - Add #region/#endregion contracts to all ~140 functions (INV_1 compliance) - Update docker files, entrypoint, build scripts for new package layout - Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps) - Add specs for 036-039 feature plans
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
# agent/tests/test_agent/test_agent_context.py
|
|
# #region Test.Agent.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation,uicontext]
|
|
# @BRIEF Contract tests for UIContext validation — enum checks, size limits, field lengths, boundary values.
|
|
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
|
|
# @TEST_FIXTURE: null -> returns {}
|
|
# @TEST_FIXTURE: dashboard+id+name -> passes
|
|
# @TEST_FIXTURE: malformed_objectType -> raises UIContextValidationError
|
|
# @TEST_FIXTURE: objectName>256 -> raises
|
|
# @TEST_FIXTURE: oversized>4KB -> raises
|
|
# @TEST_FIXTURE: contextVersion!=1 -> raises
|
|
# @TEST_FIXTURE: dataset_context -> passes
|
|
# @TEST_FIXTURE: migration_context -> passes
|
|
# @TEST_FIXTURE: non_numeric_objectId -> raises
|
|
# @TEST_FIXTURE: empty_route -> passes
|
|
|
|
|
|
import pytest
|
|
|
|
from ss_tools.agent._context import UIContextValidationError, validate_uicontext
|
|
|
|
|
|
# #region test_null_payload_returns_empty [C:2] [TYPE Function]
|
|
def test_null_payload_returns_empty_dict():
|
|
"""Null payloads should safely return an empty dict."""
|
|
assert validate_uicontext(None) == {}
|
|
# #endregion test_null_payload_returns_empty
|
|
|
|
|
|
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function]
|
|
def test_valid_dashboard_context_passes():
|
|
"""Full dashboard UIContext with all fields should validate cleanly."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"objectName": "Energy Report",
|
|
"envId": "ss-dev",
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
}
|
|
result = validate_uicontext(payload)
|
|
assert result == payload
|
|
# #endregion test_valid_dashboard_context_passes
|
|
|
|
|
|
# #region test_valid_dataset_context_passes [C:2] [TYPE Function]
|
|
def test_valid_dataset_context_passes():
|
|
"""Dataset UIContext should pass validation."""
|
|
payload = {
|
|
"objectType": "dataset",
|
|
"objectId": "15",
|
|
"objectName": None,
|
|
"envId": "staging",
|
|
"route": "/datasets/15",
|
|
"contextVersion": 1,
|
|
}
|
|
assert validate_uicontext(payload) == payload
|
|
# #endregion test_valid_dataset_context_passes
|
|
|
|
|
|
# #region test_valid_migration_context_passes [C:2] [TYPE Function]
|
|
def test_valid_migration_context_passes():
|
|
"""Migration UIContext should pass validation."""
|
|
payload = {
|
|
"objectType": "migration",
|
|
"objectId": None,
|
|
"objectName": None,
|
|
"envId": None,
|
|
"route": "/migrations",
|
|
"contextVersion": 1,
|
|
}
|
|
assert validate_uicontext(payload) == payload
|
|
# #endregion test_valid_migration_context_passes
|
|
|
|
|
|
# #region test_invalid_object_type_raises [C:2] [TYPE Function]
|
|
def test_invalid_object_type_raises_validation_error():
|
|
"""Unknown objectType values should be rejected."""
|
|
payload = {
|
|
"objectType": "chart",
|
|
"objectId": "42",
|
|
"envId": "ss-dev",
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="objectType"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_invalid_object_type_raises
|
|
|
|
|
|
# #region test_invalid_object_id_raises [C:2] [TYPE Function]
|
|
def test_non_numeric_object_id_raises():
|
|
"""ObjectId must be a numeric string or None."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "abc-not-a-number",
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="objectId"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_invalid_object_id_raises
|
|
|
|
|
|
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function]
|
|
def test_object_name_exceeds_256_chars_raises():
|
|
"""ObjectName longer than 256 characters should be rejected."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"objectName": "A" * 257,
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="objectName"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_object_name_exceeds_limit_raises
|
|
|
|
|
|
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function]
|
|
def test_object_name_at_256_chars_passes():
|
|
"""ObjectName at exactly 256 characters should pass."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"objectName": "A" * 256,
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
}
|
|
assert validate_uicontext(payload) == payload
|
|
# #endregion test_object_name_at_boundary_passes
|
|
|
|
|
|
# #region test_invalid_context_version_raises [C:2] [TYPE Function]
|
|
def test_invalid_context_version_raises():
|
|
"""Only contextVersion=1 is currently supported."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 2,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="contextVersion"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_invalid_context_version_raises
|
|
|
|
|
|
# #region test_missing_context_version_raises [C:2] [TYPE Function]
|
|
def test_missing_context_version_raises():
|
|
"""ContextVersion should always be present and equal 1."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"route": "/dashboards/42",
|
|
}
|
|
with pytest.raises(UIContextValidationError):
|
|
validate_uicontext(payload)
|
|
# #endregion test_missing_context_version_raises
|
|
|
|
|
|
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function]
|
|
def test_payload_exceeds_4kb_raises():
|
|
"""Payloads larger than 4 KB should be rejected to prevent prompt injection."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"route": "/dashboards/42",
|
|
"contextVersion": 1,
|
|
# Create a field that pushes the serialized JSON over 4096 bytes
|
|
"padding": "X" * 4096,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_payload_exceeds_4kb_raises
|
|
|
|
|
|
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function]
|
|
def test_route_exceeds_512_chars_raises():
|
|
"""Route length should be capped at 512 characters."""
|
|
payload = {
|
|
"objectType": "dashboard",
|
|
"objectId": "42",
|
|
"route": "/" + "a" * 512,
|
|
"contextVersion": 1,
|
|
}
|
|
with pytest.raises(UIContextValidationError, match="route"):
|
|
validate_uicontext(payload)
|
|
# #endregion test_route_exceeds_512_chars_raises
|
|
|
|
|
|
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function]
|
|
def test_env_id_none_and_string_accepted():
|
|
"""envId should accept None and string values."""
|
|
assert validate_uicontext({
|
|
"objectType": "dashboard", "objectId": "42",
|
|
"envId": None, "route": "/dashboards/42", "contextVersion": 1,
|
|
})["envId"] is None
|
|
|
|
assert validate_uicontext({
|
|
"objectType": "dashboard", "objectId": "42",
|
|
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
|
|
})["envId"] == "ss-dev"
|
|
# #endregion test_env_id_none_and_string_accepted
|
|
|
|
|
|
# #region test_without_object_type_passes [C:2] [TYPE Function]
|
|
def test_no_object_type_with_env_passes():
|
|
"""Context without objectType (general mode) should pass validation."""
|
|
payload = {
|
|
"objectType": None,
|
|
"objectId": None,
|
|
"envId": "ss-dev",
|
|
"route": "/settings",
|
|
"contextVersion": 1,
|
|
}
|
|
assert validate_uicontext(payload) == payload
|
|
# #endregion test_without_object_type_passes
|
|
|
|
# #endregion Test.Agent.Context
|