Implement addressable artifacts with content-based identity and change detection. Core Features: - Artifact model with SHA-256 content digests - ArtifactReference for cross-space addressing - IArtifactRepository interface for pluggable storage - SQLiteArtifactRepository implementation - ArtifactService for high-level operations - Content digest calculation utilities Database: - prompt_artifacts table with indexes - Support for artifact metadata and types - UNIQUE constraint on space_id+name Tests (41 passing): - 26 model tests (metadata, artifacts, references, digests) - 15 repository tests (CRUD, queries, constraints) Implements: - FR-1.1: Unique addressability by name and ID - FR-1.2: Content digest computation and storage - FR-1.3: Cross-space artifact references Files Created: - markitect/prompts/models.py - markitect/prompts/repositories/interfaces.py - markitect/prompts/repositories/sqlite.py - markitect/prompts/services/artifact_service.py - migrations/prompts/001_create_artifacts_table.sql - tests/unit/prompts/test_artifact_models.py - tests/unit/prompts/test_artifact_repository.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""
|
|
Unit tests for artifact models.
|
|
|
|
Tests Artifact, ArtifactMetadata, ArtifactReference, and content digest
|
|
calculation functions.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
|
|
from markitect.prompts.models import (
|
|
Artifact,
|
|
ArtifactMetadata,
|
|
ArtifactReference,
|
|
ArtifactType,
|
|
calculate_content_digest,
|
|
calculate_bundle_digest,
|
|
)
|
|
|
|
|
|
class TestArtifactMetadata:
|
|
"""Tests for ArtifactMetadata."""
|
|
|
|
def test_create_empty_metadata(self):
|
|
"""Test creating metadata with defaults."""
|
|
metadata = ArtifactMetadata()
|
|
assert metadata.description is None
|
|
assert metadata.tags == []
|
|
assert metadata.author is None
|
|
assert metadata.version is None
|
|
assert metadata.custom == {}
|
|
|
|
def test_create_metadata_with_values(self):
|
|
"""Test creating metadata with values."""
|
|
metadata = ArtifactMetadata(
|
|
description="Test artifact",
|
|
tags=["test", "example"],
|
|
author="test-author",
|
|
version="1.0.0",
|
|
custom={"key": "value"}
|
|
)
|
|
assert metadata.description == "Test artifact"
|
|
assert metadata.tags == ["test", "example"]
|
|
assert metadata.author == "test-author"
|
|
assert metadata.version == "1.0.0"
|
|
assert metadata.custom == {"key": "value"}
|
|
|
|
def test_metadata_to_dict(self):
|
|
"""Test metadata serialization to dict."""
|
|
metadata = ArtifactMetadata(
|
|
description="Test",
|
|
tags=["tag1"],
|
|
author="author",
|
|
)
|
|
data = metadata.to_dict()
|
|
assert data["description"] == "Test"
|
|
assert data["tags"] == ["tag1"]
|
|
assert data["author"] == "author"
|
|
assert data["version"] is None
|
|
assert data["custom"] == {}
|
|
|
|
def test_metadata_from_dict(self):
|
|
"""Test metadata deserialization from dict."""
|
|
data = {
|
|
"description": "Test",
|
|
"tags": ["tag1", "tag2"],
|
|
"author": "author",
|
|
"version": "2.0",
|
|
"custom": {"extra": "data"}
|
|
}
|
|
metadata = ArtifactMetadata.from_dict(data)
|
|
assert metadata.description == "Test"
|
|
assert metadata.tags == ["tag1", "tag2"]
|
|
assert metadata.author == "author"
|
|
assert metadata.version == "2.0"
|
|
assert metadata.custom == {"extra": "data"}
|
|
|
|
|
|
class TestArtifact:
|
|
"""Tests for Artifact model."""
|
|
|
|
def test_create_artifact(self):
|
|
"""Test artifact creation with automatic digest calculation."""
|
|
content = "# Test Content\n\nThis is a test."
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="test-artifact",
|
|
content=content,
|
|
)
|
|
|
|
assert artifact.id # UUID generated
|
|
assert artifact.space_id == "space-1"
|
|
assert artifact.name == "test-artifact"
|
|
assert artifact.artifact_type == ArtifactType.CONTENT
|
|
assert artifact.content_digest == calculate_content_digest(content)
|
|
assert artifact.content_size == len(content.encode('utf-8'))
|
|
assert isinstance(artifact.metadata, ArtifactMetadata)
|
|
assert isinstance(artifact.created_at, datetime)
|
|
assert isinstance(artifact.updated_at, datetime)
|
|
|
|
def test_create_artifact_with_type(self):
|
|
"""Test creating artifact with specific type."""
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="my-template",
|
|
content="Template content",
|
|
artifact_type=ArtifactType.TEMPLATE,
|
|
)
|
|
assert artifact.artifact_type == ArtifactType.TEMPLATE
|
|
|
|
def test_create_artifact_with_metadata(self):
|
|
"""Test creating artifact with metadata."""
|
|
metadata = ArtifactMetadata(description="Test", tags=["tag1"])
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="test",
|
|
content="content",
|
|
metadata=metadata,
|
|
)
|
|
assert artifact.metadata.description == "Test"
|
|
assert artifact.metadata.tags == ["tag1"]
|
|
|
|
def test_update_content(self):
|
|
"""Test updating artifact content."""
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="test",
|
|
content="original content",
|
|
)
|
|
original_digest = artifact.content_digest
|
|
original_updated_at = artifact.updated_at
|
|
|
|
# Small delay to ensure timestamp difference
|
|
import time
|
|
time.sleep(0.01)
|
|
|
|
new_content = "updated content"
|
|
artifact.update_content(new_content)
|
|
|
|
assert artifact.content_digest != original_digest
|
|
assert artifact.content_digest == calculate_content_digest(new_content)
|
|
assert artifact.content_size == len(new_content.encode('utf-8'))
|
|
assert artifact.updated_at > original_updated_at
|
|
|
|
def test_has_changed(self):
|
|
"""Test change detection."""
|
|
content = "original"
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="test",
|
|
content=content,
|
|
)
|
|
|
|
# Same content - no change
|
|
assert not artifact.has_changed(calculate_content_digest(content))
|
|
|
|
# Different content - changed
|
|
assert artifact.has_changed(calculate_content_digest("different"))
|
|
|
|
def test_artifact_to_dict(self):
|
|
"""Test artifact serialization."""
|
|
artifact = Artifact.create(
|
|
space_id="space-1",
|
|
name="test",
|
|
content="content",
|
|
artifact_type=ArtifactType.TEMPLATE,
|
|
)
|
|
data = artifact.to_dict()
|
|
|
|
assert data["id"] == artifact.id
|
|
assert data["space_id"] == "space-1"
|
|
assert data["name"] == "test"
|
|
assert data["artifact_type"] == "template"
|
|
assert data["content_digest"] == artifact.content_digest
|
|
assert data["content_size"] == artifact.content_size
|
|
assert "metadata" in data
|
|
assert "created_at" in data
|
|
assert "updated_at" in data
|
|
|
|
def test_artifact_from_dict(self):
|
|
"""Test artifact deserialization."""
|
|
original = Artifact.create(
|
|
space_id="space-1",
|
|
name="test",
|
|
content="content",
|
|
)
|
|
data = original.to_dict()
|
|
restored = Artifact.from_dict(data)
|
|
|
|
assert restored.id == original.id
|
|
assert restored.space_id == original.space_id
|
|
assert restored.name == original.name
|
|
assert restored.artifact_type == original.artifact_type
|
|
assert restored.content_digest == original.content_digest
|
|
assert restored.content_size == original.content_size
|
|
|
|
|
|
class TestArtifactReference:
|
|
"""Tests for ArtifactReference."""
|
|
|
|
def test_simple_reference(self):
|
|
"""Test simple artifact name reference."""
|
|
ref = ArtifactReference(name="my-artifact")
|
|
assert ref.name == "my-artifact"
|
|
assert ref.space_id is None
|
|
assert ref.version is None
|
|
assert str(ref) == "my-artifact"
|
|
|
|
def test_reference_with_space(self):
|
|
"""Test reference with space ID."""
|
|
ref = ArtifactReference(name="artifact", space_id="space-1")
|
|
assert ref.name == "artifact"
|
|
assert ref.space_id == "space-1"
|
|
assert str(ref) == "space-1:artifact"
|
|
|
|
def test_reference_with_version(self):
|
|
"""Test reference with version."""
|
|
ref = ArtifactReference(name="artifact", version="1.0.0")
|
|
assert ref.name == "artifact"
|
|
assert ref.version == "1.0.0"
|
|
assert str(ref) == "artifact@1.0.0"
|
|
|
|
def test_reference_with_space_and_version(self):
|
|
"""Test fully qualified reference."""
|
|
ref = ArtifactReference(
|
|
name="artifact",
|
|
space_id="space-1",
|
|
version="2.0.0"
|
|
)
|
|
assert str(ref) == "space-1:artifact@2.0.0"
|
|
|
|
def test_parse_simple_reference(self):
|
|
"""Test parsing simple reference."""
|
|
ref = ArtifactReference.parse("my-artifact")
|
|
assert ref.name == "my-artifact"
|
|
assert ref.space_id is None
|
|
assert ref.version is None
|
|
|
|
def test_parse_space_reference(self):
|
|
"""Test parsing space:artifact reference."""
|
|
ref = ArtifactReference.parse("space-1:my-artifact")
|
|
assert ref.name == "my-artifact"
|
|
assert ref.space_id == "space-1"
|
|
assert ref.version is None
|
|
|
|
def test_parse_version_reference(self):
|
|
"""Test parsing artifact@version reference."""
|
|
ref = ArtifactReference.parse("my-artifact@1.0.0")
|
|
assert ref.name == "my-artifact"
|
|
assert ref.space_id is None
|
|
assert ref.version == "1.0.0"
|
|
|
|
def test_parse_full_reference(self):
|
|
"""Test parsing space:artifact@version reference."""
|
|
ref = ArtifactReference.parse("space-1:my-artifact@2.0.0")
|
|
assert ref.name == "my-artifact"
|
|
assert ref.space_id == "space-1"
|
|
assert ref.version == "2.0.0"
|
|
|
|
def test_parse_invalid_reference(self):
|
|
"""Test parsing invalid reference raises error."""
|
|
with pytest.raises(ValueError, match="Invalid artifact reference"):
|
|
ArtifactReference.parse("space-1:")
|
|
|
|
with pytest.raises(ValueError, match="Invalid artifact reference"):
|
|
ArtifactReference.parse("@1.0.0")
|
|
|
|
|
|
class TestContentDigest:
|
|
"""Tests for content digest functions."""
|
|
|
|
def test_calculate_content_digest(self):
|
|
"""Test SHA-256 digest calculation."""
|
|
content = "Hello, World!"
|
|
digest = calculate_content_digest(content)
|
|
# SHA-256 produces 64 hex characters
|
|
assert len(digest) == 64
|
|
assert digest.isalnum()
|
|
|
|
def test_digest_consistency(self):
|
|
"""Test digest is consistent for same content."""
|
|
content = "Test content"
|
|
digest1 = calculate_content_digest(content)
|
|
digest2 = calculate_content_digest(content)
|
|
assert digest1 == digest2
|
|
|
|
def test_digest_changes_with_content(self):
|
|
"""Test digest changes when content changes."""
|
|
digest1 = calculate_content_digest("content 1")
|
|
digest2 = calculate_content_digest("content 2")
|
|
assert digest1 != digest2
|
|
|
|
def test_calculate_bundle_digest(self):
|
|
"""Test bundle digest calculation."""
|
|
components = {
|
|
"template": "abc123",
|
|
"dep1": "def456",
|
|
"dep2": "ghi789",
|
|
}
|
|
digest = calculate_bundle_digest(components)
|
|
assert len(digest) == 64
|
|
|
|
def test_bundle_digest_order_independent(self):
|
|
"""Test bundle digest is deterministic regardless of dict order."""
|
|
components1 = {"a": "1", "b": "2", "c": "3"}
|
|
components2 = {"c": "3", "a": "1", "b": "2"}
|
|
assert calculate_bundle_digest(components1) == calculate_bundle_digest(components2)
|
|
|
|
def test_bundle_digest_sensitive_to_values(self):
|
|
"""Test bundle digest changes when values change."""
|
|
digest1 = calculate_bundle_digest({"a": "1", "b": "2"})
|
|
digest2 = calculate_bundle_digest({"a": "1", "b": "3"})
|
|
assert digest1 != digest2
|