""" 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}"