ai-robot-core/ai-service/tests/test_metadata_governance_co...

733 lines
23 KiB
Python
Raw Normal View History

"""
Contract tests for Metadata Governance module.
[AC-IDSMETA-13~22] Verify provider API matches openapi.provider.yaml contract.
Contract Level: L2
Reference: spec/metadata-governance/openapi.provider.yaml
"""
import pytest
from pydantic import ValidationError
from typing import Any
class MetadataSchema:
"""
[AC-IDSMETA-13] MetadataSchema contract model.
Matches openapi.provider.yaml MetadataSchema schema.
"""
def __init__(
self,
id: str,
field_key: str,
label: str,
type: str,
required: bool,
scope: list[str],
status: str,
options: list[str] | None = None,
default: str | int | float | bool | None = None,
is_filterable: bool = True,
is_rank_feature: bool = False,
):
self.id = id
self.field_key = field_key
self.label = label
self.type = type
self.required = required
self.scope = scope
self.status = status
self.options = options
self.default = default
self.is_filterable = is_filterable
self.is_rank_feature = is_rank_feature
def validate(self) -> tuple[bool, list[str]]:
errors = []
if not self.field_key:
errors.append("field_key is required")
if not self.label:
errors.append("label is required")
if self.type not in ["string", "number", "boolean", "enum", "array_enum"]:
errors.append(f"Invalid type: {self.type}")
if self.status not in ["draft", "active", "deprecated"]:
errors.append(f"Invalid status: {self.status}")
if not self.scope:
errors.append("scope must have at least one item")
for s in self.scope:
if s not in ["kb_document", "intent_rule", "script_flow", "prompt_template"]:
errors.append(f"Invalid scope value: {s}")
return len(errors) == 0, errors
class MetadataSchemaCreateRequest:
"""
[AC-IDSMETA-13] Create request contract model.
"""
VALID_FIELD_KEY_PATTERN = r"^[a-z0-9_]+$"
VALID_TYPES = ["string", "number", "boolean", "enum", "array_enum"]
VALID_STATUSES = ["draft", "active", "deprecated"]
VALID_SCOPES = ["kb_document", "intent_rule", "script_flow", "prompt_template"]
def __init__(
self,
field_key: str,
label: str,
type: str,
required: bool,
scope: list[str],
status: str,
options: list[str] | None = None,
default: str | int | float | bool | None = None,
is_filterable: bool = True,
is_rank_feature: bool = False,
):
self.field_key = field_key
self.label = label
self.type = type
self.required = required
self.scope = scope
self.status = status
self.options = options
self.default = default
self.is_filterable = is_filterable
self.is_rank_feature = is_rank_feature
def validate(self) -> tuple[bool, list[str]]:
import re
errors = []
if not self.field_key or len(self.field_key) < 1 or len(self.field_key) > 64:
errors.append("field_key must be 1-64 characters")
elif not re.match(self.VALID_FIELD_KEY_PATTERN, self.field_key):
errors.append(f"field_key must match pattern {self.VALID_FIELD_KEY_PATTERN}")
if not self.label or len(self.label) < 1 or len(self.label) > 64:
errors.append("label must be 1-64 characters")
if self.type not in self.VALID_TYPES:
errors.append(f"type must be one of {self.VALID_TYPES}")
if self.status not in self.VALID_STATUSES:
errors.append(f"status must be one of {self.VALID_STATUSES}")
if not self.scope or len(self.scope) < 1:
errors.append("scope must have at least one item")
else:
for s in self.scope:
if s not in self.VALID_SCOPES:
errors.append(f"Invalid scope value: {s}")
if self.type in ["enum", "array_enum"] and (not self.options or len(self.options) == 0):
errors.append(f"type '{self.type}' requires non-empty options")
if self.options:
if len(self.options) != len(set(self.options)):
errors.append("options must have unique values")
return len(errors) == 0, errors
class MetadataSchemaUpdateRequest:
"""
[AC-IDSMETA-14] Update request contract model.
"""
VALID_STATUSES = ["draft", "active", "deprecated"]
VALID_SCOPES = ["kb_document", "intent_rule", "script_flow", "prompt_template"]
def __init__(
self,
label: str | None = None,
required: bool | None = None,
options: list[str] | None = None,
default: str | int | float | bool | None = None,
scope: list[str] | None = None,
is_filterable: bool | None = None,
is_rank_feature: bool | None = None,
status: str | None = None,
):
self.label = label
self.required = required
self.options = options
self.default = default
self.scope = scope
self.is_filterable = is_filterable
self.is_rank_feature = is_rank_feature
self.status = status
def validate(self) -> tuple[bool, list[str]]:
errors = []
if self.label is not None and (len(self.label) < 1 or len(self.label) > 64):
errors.append("label must be 1-64 characters")
if self.status is not None and self.status not in self.VALID_STATUSES:
errors.append(f"status must be one of {self.VALID_STATUSES}")
if self.scope is not None:
if len(self.scope) < 1:
errors.append("scope must have at least one item")
else:
for s in self.scope:
if s not in self.VALID_SCOPES:
errors.append(f"Invalid scope value: {s}")
if self.options is not None:
if len(self.options) != len(set(self.options)):
errors.append("options must have unique values")
return len(errors) == 0, errors
class DecompositionTemplate:
"""
[AC-IDSMETA-21, AC-IDSMETA-22] DecompositionTemplate contract model.
"""
VALID_VERSION_PATTERN = r"^v?[0-9]+\.[0-9]+\.[0-9]+$"
VALID_STATUSES = ["draft", "active", "deprecated"]
def __init__(
self,
id: str,
name: str,
template_content: str,
version: str,
status: str,
):
self.id = id
self.name = name
self.template_content = template_content
self.version = version
self.status = status
def validate(self) -> tuple[bool, list[str]]:
import re
errors = []
if not self.name or len(self.name) < 1 or len(self.name) > 100:
errors.append("name must be 1-100 characters")
if not self.template_content or len(self.template_content) < 20:
errors.append("template_content must be at least 20 characters")
if not re.match(self.VALID_VERSION_PATTERN, self.version):
errors.append(f"version must match pattern {self.VALID_VERSION_PATTERN}")
if self.status not in self.VALID_STATUSES:
errors.append(f"status must be one of {self.VALID_STATUSES}")
return len(errors) == 0, errors
class ErrorResponse:
"""
Error response contract model.
"""
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
self.code = code
self.message = message
self.details = details
def validate(self) -> tuple[bool, list[str]]:
errors = []
if not self.code:
errors.append("code is required")
if not self.message:
errors.append("message is required")
return len(errors) == 0, errors
class TestMetadataSchemaContract:
"""
[AC-IDSMETA-13] Test MetadataSchema matches OpenAPI contract.
"""
def test_required_fields_present(self):
"""MetadataSchema must have all required fields."""
schema = MetadataSchema(
id="test-id",
field_key="grade",
label="年级",
type="enum",
required=True,
scope=["kb_document"],
status="active",
)
is_valid, errors = schema.validate()
assert is_valid, f"Validation failed: {errors}"
def test_field_key_pattern_validation(self):
"""field_key must match ^[a-z0-9_]+$ pattern."""
valid_keys = ["grade", "subject_name", "type1", "kb_type"]
for key in valid_keys:
schema = MetadataSchema(
id="test-id",
field_key=key,
label="Test",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, _ = schema.validate()
assert is_valid, f"Valid key '{key}' should pass"
def test_field_key_rejects_invalid(self):
"""field_key must reject invalid patterns."""
invalid_keys = ["Grade", "subject-name", "test key", "test.key"]
for key in invalid_keys:
request = MetadataSchemaCreateRequest(
field_key=key,
label="Test",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, _ = request.validate()
assert not is_valid, f"Invalid key '{key}' should fail"
def test_type_enum_values(self):
"""type must be one of: string, number, boolean, enum, array_enum."""
valid_types = ["string", "number", "boolean", "enum", "array_enum"]
for t in valid_types:
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type=t,
required=False,
scope=["kb_document"],
status="active",
)
is_valid, _ = schema.validate()
assert is_valid, f"Valid type '{t}' should pass"
def test_type_rejects_invalid(self):
"""type must reject invalid values."""
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="invalid_type",
required=False,
scope=["kb_document"],
status="active",
)
is_valid, _ = schema.validate()
assert not is_valid
def test_status_enum_values(self):
"""status must be one of: draft, active, deprecated."""
valid_statuses = ["draft", "active", "deprecated"]
for s in valid_statuses:
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="string",
required=False,
scope=["kb_document"],
status=s,
)
is_valid, _ = schema.validate()
assert is_valid, f"Valid status '{s}' should pass"
def test_scope_enum_values(self):
"""scope items must be valid."""
valid_scopes = [
["kb_document"],
["intent_rule"],
["script_flow"],
["prompt_template"],
["kb_document", "intent_rule"],
]
for scope in valid_scopes:
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="string",
required=False,
scope=scope,
status="active",
)
is_valid, _ = schema.validate()
assert is_valid, f"Valid scope '{scope}' should pass"
def test_scope_rejects_invalid(self):
"""scope must reject invalid values."""
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="string",
required=False,
scope=["invalid_scope"],
status="active",
)
is_valid, _ = schema.validate()
assert not is_valid
def test_scope_requires_at_least_one(self):
"""scope must have at least one item."""
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="string",
required=False,
scope=[],
status="active",
)
is_valid, _ = schema.validate()
assert not is_valid
class TestMetadataSchemaCreateRequestContract:
"""
[AC-IDSMETA-13] Test MetadataSchemaCreateRequest validation.
"""
def test_valid_create_request(self):
"""Valid create request should pass."""
request = MetadataSchemaCreateRequest(
field_key="grade",
label="年级",
type="enum",
required=True,
scope=["kb_document"],
status="draft",
options=["初一", "初二", "初三"],
)
is_valid, errors = request.validate()
assert is_valid, f"Validation failed: {errors}"
def test_enum_type_requires_options(self):
"""[AC-IDSMETA-03] enum type requires non-empty options."""
request = MetadataSchemaCreateRequest(
field_key="grade",
label="年级",
type="enum",
required=True,
scope=["kb_document"],
status="draft",
options=None,
)
is_valid, _ = request.validate()
assert not is_valid
def test_array_enum_type_requires_options(self):
"""[AC-IDSMETA-03] array_enum type requires non-empty options."""
request = MetadataSchemaCreateRequest(
field_key="subjects",
label="学科",
type="array_enum",
required=False,
scope=["kb_document"],
status="draft",
options=None,
)
is_valid, _ = request.validate()
assert not is_valid
def test_options_must_be_unique(self):
"""[AC-IDSMETA-03] options must have unique values."""
request = MetadataSchemaCreateRequest(
field_key="grade",
label="年级",
type="enum",
required=True,
scope=["kb_document"],
status="draft",
options=["初一", "初一", "初二"],
)
is_valid, _ = request.validate()
assert not is_valid
def test_field_key_length_constraints(self):
"""field_key must be 1-64 characters."""
request = MetadataSchemaCreateRequest(
field_key="",
label="Test",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, _ = request.validate()
assert not is_valid
request = MetadataSchemaCreateRequest(
field_key="a" * 65,
label="Test",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, _ = request.validate()
assert not is_valid
def test_label_length_constraints(self):
"""label must be 1-64 characters."""
request = MetadataSchemaCreateRequest(
field_key="test",
label="",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, _ = request.validate()
assert not is_valid
class TestMetadataSchemaUpdateRequestContract:
"""
[AC-IDSMETA-14] Test MetadataSchemaUpdateRequest validation.
"""
def test_valid_update_request(self):
"""Valid update request should pass."""
request = MetadataSchemaUpdateRequest(
label="更新后的标签",
status="deprecated",
)
is_valid, errors = request.validate()
assert is_valid, f"Validation failed: {errors}"
def test_partial_update(self):
"""Partial update with only some fields should pass."""
request = MetadataSchemaUpdateRequest(status="active")
is_valid, _ = request.validate()
assert is_valid
def test_empty_update(self):
"""Empty update should pass (all fields optional)."""
request = MetadataSchemaUpdateRequest()
is_valid, _ = request.validate()
assert is_valid
def test_status_transition_to_deprecated(self):
"""[AC-IDSMETA-14] Status can be updated to deprecated."""
request = MetadataSchemaUpdateRequest(status="deprecated")
is_valid, _ = request.validate()
assert is_valid
class TestDecompositionTemplateContract:
"""
[AC-IDSMETA-21, AC-IDSMETA-22] Test DecompositionTemplate validation.
"""
def test_valid_template(self):
"""Valid template should pass."""
template = DecompositionTemplate(
id="template-1",
name="数据拆解模板",
template_content="这是一个数据拆解模板,用于分析和归类待录入文本...",
version="1.0.0",
status="active",
)
is_valid, errors = template.validate()
assert is_valid, f"Validation failed: {errors}"
def test_version_format_with_v_prefix(self):
"""version can have optional 'v' prefix."""
template = DecompositionTemplate(
id="template-1",
name="Test",
template_content="a" * 20,
version="v1.0.0",
status="active",
)
is_valid, _ = template.validate()
assert is_valid
def test_version_format_without_v_prefix(self):
"""version can be without 'v' prefix."""
template = DecompositionTemplate(
id="template-1",
name="Test",
template_content="a" * 20,
version="2.1.3",
status="active",
)
is_valid, _ = template.validate()
assert is_valid
def test_version_rejects_invalid_format(self):
"""version must match semver pattern."""
invalid_versions = ["1.0", "v1", "1.0.0.0", "latest"]
for v in invalid_versions:
template = DecompositionTemplate(
id="template-1",
name="Test",
template_content="a" * 20,
version=v,
status="active",
)
is_valid, _ = template.validate()
assert not is_valid, f"Invalid version '{v}' should fail"
def test_template_content_min_length(self):
"""template_content must be at least 20 characters."""
template = DecompositionTemplate(
id="template-1",
name="Test",
template_content="short",
version="1.0.0",
status="active",
)
is_valid, _ = template.validate()
assert not is_valid
def test_name_length_constraints(self):
"""name must be 1-100 characters."""
template = DecompositionTemplate(
id="template-1",
name="",
template_content="a" * 20,
version="1.0.0",
status="active",
)
is_valid, _ = template.validate()
assert not is_valid
template = DecompositionTemplate(
id="template-1",
name="a" * 101,
template_content="a" * 20,
version="1.0.0",
status="active",
)
is_valid, _ = template.validate()
assert not is_valid
class TestErrorResponseContract:
"""
Test ErrorResponse matches OpenAPI contract.
"""
def test_required_fields(self):
"""ErrorResponse must have code and message."""
response = ErrorResponse(
code="VALIDATION_ERROR",
message="Invalid request",
)
is_valid, errors = response.validate()
assert is_valid, f"Validation failed: {errors}"
def test_optional_details(self):
"""ErrorResponse can have optional details."""
response = ErrorResponse(
code="VALIDATION_ERROR",
message="Multiple validation errors",
details={"fields": ["field_key", "label"]},
)
is_valid, _ = response.validate()
assert is_valid
class TestACTraceability:
"""
[QA-IDSMETA-01] Verify AC traceability in openapi.provider.yaml.
"""
def test_ac_idsmeta_13_traceability(self):
"""
[AC-IDSMETA-13] Verify field status management (draft/active/deprecated).
OpenAPI: listMetadataSchemas, createMetadataSchema
"""
valid_statuses = ["draft", "active", "deprecated"]
for status in valid_statuses:
schema = MetadataSchema(
id="test-id",
field_key="test",
label="Test",
type="string",
required=False,
scope=["kb_document"],
status=status,
)
is_valid, _ = schema.validate()
assert is_valid
def test_ac_idsmeta_14_traceability(self):
"""
[AC-IDSMETA-14] Verify deprecated field handling.
OpenAPI: updateMetadataSchema with status=deprecated
"""
request = MetadataSchemaUpdateRequest(status="deprecated")
is_valid, _ = request.validate()
assert is_valid
def test_ac_idsmeta_21_22_traceability(self):
"""
[AC-IDSMETA-21, AC-IDSMETA-22] Verify decomposition template contract.
OpenAPI: listDecompositionTemplates, createDecompositionTemplate
"""
template = DecompositionTemplate(
id="template-1",
name="拆解模板",
template_content="这是一个拆解模板,用于分析待录入文本的归类...",
version="1.0.0",
status="active",
)
is_valid, _ = template.validate()
assert is_valid
class TestContractLevelCompliance:
"""
Verify L2 contract level compliance.
"""
def test_schema_completeness(self):
"""L2 requires complete schema with required/optional fields clearly defined."""
schema = MetadataSchema(
id="test-id",
field_key="grade",
label="年级",
type="enum",
required=True,
scope=["kb_document", "intent_rule"],
status="active",
options=["初一", "初二", "初三"],
default="初一",
is_filterable=True,
is_rank_feature=False,
)
is_valid, _ = schema.validate()
assert is_valid
def test_error_response_schema(self):
"""L2 requires defined error response schema."""
error = ErrorResponse(
code="SCHEMA_NOT_FOUND",
message="Metadata schema not found",
details={"schema_id": "non-existent-id"},
)
is_valid, _ = error.validate()
assert is_valid
def test_field_validation_rules(self):
"""L2 requires clear field validation rules."""
request = MetadataSchemaCreateRequest(
field_key="valid_key_123",
label="Valid Label",
type="string",
required=False,
scope=["kb_document"],
status="draft",
)
is_valid, errors = request.validate()
assert is_valid, f"Validation failed: {errors}"