"""Property-basierte Tests für den Wissensspeicher: Artefakt-Ingestion. **Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21** Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index - For any gültiges Wissensartefakt aus einem beliebigen Kontext, nach der Verarbeitung durch die ETL-Pipeline muss es im YAML-Index erscheinen mit korrektem YAML-Frontmatter (mindestens Typ, Titel, Tags, Quellkontext, Erstelldatum, Content-Hash) und der Pfad muss der Scope-basierten Ordnerstruktur entsprechen. """ from __future__ import annotations from pathlib import Path from hypothesis import given, settings from hypothesis import strategies as st from monorepo.knowledge.etl import ETLPipeline from monorepo.knowledge.index import YAMLIndex from monorepo.knowledge.sources.markdown import MarkdownSource from monorepo.models import ArtifactType, Context # --- Constants --- # Valid artifact types from the model VALID_ARTIFACT_TYPES = [t.value for t in ArtifactType] # Valid contexts (non-shared, as source contexts for artifacts) VALID_CONTEXTS = [c.value for c in Context if c != Context.SHARED] # --- Strategies --- @st.composite def valid_tag(draw: st.DrawFn) -> str: """Generates a valid tag: lowercase alphanumeric with optional hyphens.""" return draw( st.from_regex(r"[a-z][a-z0-9\-]{0,14}[a-z0-9]", fullmatch=True) ) @st.composite def valid_artifact_name(draw: st.DrawFn) -> str: """Generates a valid artifact filename (kebab-case, no extension).""" return draw( st.from_regex(r"[a-z][a-z0-9\-]{1,20}[a-z0-9]", fullmatch=True) ) @st.composite def valid_title(draw: st.DrawFn) -> str: """Generates a valid artifact title (non-empty, printable).""" return draw( st.text( alphabet=st.characters( whitelist_categories=("L", "N", "Z"), whitelist_characters="-_ ", ), min_size=3, max_size=60, ).filter(lambda t: t.strip() != "") ) @st.composite def valid_markdown_artifact(draw: st.DrawFn) -> dict[str, object]: """Generates a complete valid Markdown artifact with frontmatter. Returns a dict with: - name: filename without .md extension - type: artifact type (decision, note, etc.) - title: artifact title - tags: list of tags - content: markdown body content - context: source context """ name = draw(valid_artifact_name()) artifact_type = draw(st.sampled_from(VALID_ARTIFACT_TYPES)) title = draw(valid_title()) tags = draw(st.lists(valid_tag(), min_size=1, max_size=5, unique=True)) context = draw(st.sampled_from(VALID_CONTEXTS)) # Generate some markdown body content body_line = draw( st.text( alphabet=st.characters( whitelist_categories=("L", "N", "Z"), whitelist_characters="-_., ", ), min_size=10, max_size=100, ).filter(lambda t: t.strip() != "") ) content = f"\n# {title}\n\n{body_line}\n" return { "name": name, "type": artifact_type, "title": title, "tags": tags, "content": content, "context": context, } def _write_markdown_file(directory: Path, artifact: dict[str, object]) -> Path: """Writes a Markdown file with YAML frontmatter to the given directory. Returns the path to the written file. """ name: str = artifact["name"] # type: ignore[assignment] artifact_type: str = artifact["type"] # type: ignore[assignment] title: str = artifact["title"] # type: ignore[assignment] tags: list[str] = artifact["tags"] # type: ignore[assignment] content: str = artifact["content"] # type: ignore[assignment] # Build YAML frontmatter tags_str = ", ".join(tags) frontmatter = ( f"---\n" f"type: {artifact_type}\n" f"title: \"{title}\"\n" f"tags: [{tags_str}]\n" f"---\n" ) file_path = directory / f"{name}.md" file_path.write_text(frontmatter + content, encoding="utf-8") return file_path # --- Property Test --- class TestProperty5ArtifactIngestionMetadata: """Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index. **Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21** For any valid knowledge artifact from any context, after processing by the ETL pipeline, it must appear in the YAML index with: - id set - title matches - type matches - tags match - scope == context - content_hash starts with "sha256:" - path follows scope-based structure ({context}/{type}/{name}.md) """ @given(artifact=valid_markdown_artifact()) @settings(max_examples=50, deadline=5000) def test_ingested_artifact_has_complete_metadata_in_index( self, artifact: dict[str, object], tmp_path_factory: object ) -> None: """After ingestion, the artifact appears in the YAML index with all required metadata.""" # Use a unique temp directory for each test invocation import tempfile with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) source_dir = tmp / "source" source_dir.mkdir() store_path = tmp / "store" store_path.mkdir() # Extract artifact data name: str = artifact["name"] # type: ignore[assignment] artifact_type: str = artifact["type"] # type: ignore[assignment] title: str = artifact["title"] # type: ignore[assignment] tags: list[str] = artifact["tags"] # type: ignore[assignment] context: str = artifact["context"] # type: ignore[assignment] # Write the artifact as a Markdown file _write_markdown_file(source_dir, artifact) # Set up ETL pipeline index = YAMLIndex(store_path / "_index.yaml") index.load() pipeline = ETLPipeline(index=index, store_path=store_path) source = MarkdownSource(directory=source_dir) # Run ingestion result = pipeline.ingest(source, context) # --- Assertions --- # The artifact was processed successfully assert result.processed == 1, ( f"Expected 1 processed artifact, got {result.processed}. " f"Errors: {result.errors}" ) assert result.updated == 1, ( f"Expected 1 updated artifact, got {result.updated}." ) # The artifact appears in the YAML index expected_id = f"{context}/{artifact_type}/{name}" entry = index.get_entry(expected_id) assert entry is not None, ( f"Artifact '{expected_id}' not found in index. " f"Available entries: {list(index.entries.keys())}" ) # id is set assert entry.id == expected_id # title matches assert entry.title == title # type matches assert entry.type == artifact_type # tags match (order-independent) assert set(entry.tags) == set(tags), ( f"Tags mismatch: expected {tags}, got {entry.tags}" ) # scope == context assert entry.scope == context, ( f"Scope mismatch: expected '{context}', got '{entry.scope}'" ) # content_hash starts with "sha256:" assert entry.content_hash.startswith("sha256:"), ( f"Content hash should start with 'sha256:', got '{entry.content_hash}'" ) # Content hash has actual hex digest after prefix hex_part = entry.content_hash[len("sha256:"):] assert len(hex_part) == 64, ( f"SHA-256 hex digest should be 64 chars, got {len(hex_part)}" ) # path follows scope-based structure: {context}/{type}/{name}.md # Normalize path separators (Windows uses backslashes in Path objects) normalized_path = entry.path.replace("\\", "/") expected_path = f"{context}/{artifact_type}/{name}.md" assert normalized_path == expected_path, ( f"Path mismatch: expected '{expected_path}', got '{entry.path}'" )