feat(prompts): implement Phase 1 - Foundation (FR-1)
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>
This commit is contained in:
1
tests/unit/prompts/__init__.py
Normal file
1
tests/unit/prompts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for Prompt Dependency Resolution."""
|
||||
313
tests/unit/prompts/test_artifact_models.py
Normal file
313
tests/unit/prompts/test_artifact_models.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
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
|
||||
261
tests/unit/prompts/test_artifact_repository.py
Normal file
261
tests/unit/prompts/test_artifact_repository.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Unit tests for artifact repository.
|
||||
|
||||
Tests SQLiteArtifactRepository implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata
|
||||
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
|
||||
from markitect.prompts.repositories.interfaces import (
|
||||
ArtifactNotFoundError,
|
||||
DuplicateArtifactError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
yield db_path
|
||||
# Cleanup
|
||||
Path(db_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(temp_db):
|
||||
"""Create repository instance with temp database."""
|
||||
return SQLiteArtifactRepository(temp_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_artifact():
|
||||
"""Create sample artifact for testing."""
|
||||
return Artifact.create(
|
||||
space_id="test-space",
|
||||
name="test-artifact",
|
||||
content="# Test Content\n\nThis is test content.",
|
||||
artifact_type=ArtifactType.CONTENT,
|
||||
)
|
||||
|
||||
|
||||
class TestSQLiteArtifactRepository:
|
||||
"""Tests for SQLiteArtifactRepository."""
|
||||
|
||||
def test_create_artifact(self, repository, sample_artifact):
|
||||
"""Test creating an artifact."""
|
||||
created = repository.create(sample_artifact)
|
||||
|
||||
assert created.id == sample_artifact.id
|
||||
assert created.space_id == sample_artifact.space_id
|
||||
assert created.name == sample_artifact.name
|
||||
assert created.content_digest == sample_artifact.content_digest
|
||||
|
||||
def test_create_duplicate_artifact_raises_error(self, repository, sample_artifact):
|
||||
"""Test creating duplicate artifact raises error."""
|
||||
repository.create(sample_artifact)
|
||||
|
||||
# Try to create another artifact with same space_id and name
|
||||
duplicate = Artifact.create(
|
||||
space_id="test-space",
|
||||
name="test-artifact",
|
||||
content="Different content",
|
||||
)
|
||||
|
||||
with pytest.raises(DuplicateArtifactError, match="already exists"):
|
||||
repository.create(duplicate)
|
||||
|
||||
def test_get_by_id(self, repository, sample_artifact):
|
||||
"""Test retrieving artifact by ID."""
|
||||
repository.create(sample_artifact)
|
||||
|
||||
retrieved = repository.get_by_id(sample_artifact.id)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == sample_artifact.id
|
||||
assert retrieved.name == sample_artifact.name
|
||||
assert retrieved.content_digest == sample_artifact.content_digest
|
||||
|
||||
def test_get_by_id_not_found(self, repository):
|
||||
"""Test get_by_id returns None for non-existent ID."""
|
||||
result = repository.get_by_id("non-existent-id")
|
||||
assert result is None
|
||||
|
||||
def test_get_by_name(self, repository, sample_artifact):
|
||||
"""Test retrieving artifact by space and name."""
|
||||
repository.create(sample_artifact)
|
||||
|
||||
retrieved = repository.get_by_name("test-space", "test-artifact")
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == sample_artifact.id
|
||||
assert retrieved.space_id == "test-space"
|
||||
assert retrieved.name == "test-artifact"
|
||||
|
||||
def test_get_by_name_not_found(self, repository):
|
||||
"""Test get_by_name returns None when not found."""
|
||||
result = repository.get_by_name("test-space", "non-existent")
|
||||
assert result is None
|
||||
|
||||
def test_get_by_digest(self, repository):
|
||||
"""Test finding artifacts by content digest."""
|
||||
content = "Shared content"
|
||||
artifact1 = Artifact.create(
|
||||
space_id="space-1",
|
||||
name="artifact-1",
|
||||
content=content,
|
||||
)
|
||||
artifact2 = Artifact.create(
|
||||
space_id="space-2",
|
||||
name="artifact-2",
|
||||
content=content, # Same content, same digest
|
||||
)
|
||||
|
||||
repository.create(artifact1)
|
||||
repository.create(artifact2)
|
||||
|
||||
results = repository.get_by_digest(artifact1.content_digest)
|
||||
|
||||
assert len(results) == 2
|
||||
names = {a.name for a in results}
|
||||
assert names == {"artifact-1", "artifact-2"}
|
||||
|
||||
def test_list_by_space(self, repository):
|
||||
"""Test listing artifacts in a space."""
|
||||
artifacts = [
|
||||
Artifact.create(
|
||||
space_id="space-1",
|
||||
name=f"artifact-{i}",
|
||||
content=f"Content {i}",
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
for artifact in artifacts:
|
||||
repository.create(artifact)
|
||||
|
||||
# Add artifact in different space
|
||||
other = Artifact.create(
|
||||
space_id="space-2",
|
||||
name="other",
|
||||
content="Other content",
|
||||
)
|
||||
repository.create(other)
|
||||
|
||||
results = repository.list_by_space("space-1")
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(a.space_id == "space-1" for a in results)
|
||||
# Should be ordered by name
|
||||
assert [a.name for a in results] == ["artifact-0", "artifact-1", "artifact-2"]
|
||||
|
||||
def test_list_by_space_with_type_filter(self, repository):
|
||||
"""Test listing artifacts filtered by type."""
|
||||
artifacts = [
|
||||
Artifact.create(
|
||||
space_id="space-1",
|
||||
name="content-1",
|
||||
content="Content",
|
||||
artifact_type=ArtifactType.CONTENT,
|
||||
),
|
||||
Artifact.create(
|
||||
space_id="space-1",
|
||||
name="template-1",
|
||||
content="Template",
|
||||
artifact_type=ArtifactType.TEMPLATE,
|
||||
),
|
||||
Artifact.create(
|
||||
space_id="space-1",
|
||||
name="content-2",
|
||||
content="Content 2",
|
||||
artifact_type=ArtifactType.CONTENT,
|
||||
),
|
||||
]
|
||||
|
||||
for artifact in artifacts:
|
||||
repository.create(artifact)
|
||||
|
||||
results = repository.list_by_space("space-1", ArtifactType.CONTENT)
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(a.artifact_type == ArtifactType.CONTENT for a in results)
|
||||
|
||||
def test_update_artifact(self, repository, sample_artifact):
|
||||
"""Test updating artifact."""
|
||||
repository.create(sample_artifact)
|
||||
|
||||
# Update content
|
||||
sample_artifact.update_content("New content")
|
||||
updated = repository.update(sample_artifact)
|
||||
|
||||
# Retrieve and verify
|
||||
retrieved = repository.get_by_id(sample_artifact.id)
|
||||
assert retrieved.content_digest == updated.content_digest
|
||||
assert retrieved.updated_at >= updated.updated_at
|
||||
|
||||
def test_update_nonexistent_artifact_raises_error(self, repository):
|
||||
"""Test updating non-existent artifact raises error."""
|
||||
artifact = Artifact.create(
|
||||
space_id="test-space",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
|
||||
with pytest.raises(ArtifactNotFoundError, match="not found"):
|
||||
repository.update(artifact)
|
||||
|
||||
def test_delete_artifact(self, repository, sample_artifact):
|
||||
"""Test deleting artifact."""
|
||||
repository.create(sample_artifact)
|
||||
|
||||
result = repository.delete(sample_artifact.id)
|
||||
assert result is True
|
||||
|
||||
# Verify deleted
|
||||
retrieved = repository.get_by_id(sample_artifact.id)
|
||||
assert retrieved is None
|
||||
|
||||
def test_delete_nonexistent_artifact(self, repository):
|
||||
"""Test deleting non-existent artifact returns False."""
|
||||
result = repository.delete("non-existent-id")
|
||||
assert result is False
|
||||
|
||||
def test_exists(self, repository, sample_artifact):
|
||||
"""Test checking if artifact exists."""
|
||||
assert not repository.exists("test-space", "test-artifact")
|
||||
|
||||
repository.create(sample_artifact)
|
||||
|
||||
assert repository.exists("test-space", "test-artifact")
|
||||
assert not repository.exists("test-space", "other-artifact")
|
||||
assert not repository.exists("other-space", "test-artifact")
|
||||
|
||||
def test_artifact_metadata_persistence(self, repository):
|
||||
"""Test metadata is persisted correctly."""
|
||||
metadata = ArtifactMetadata(
|
||||
description="Test description",
|
||||
tags=["tag1", "tag2"],
|
||||
author="test-author",
|
||||
version="1.0.0",
|
||||
custom={"key": "value"},
|
||||
)
|
||||
|
||||
artifact = Artifact.create(
|
||||
space_id="test-space",
|
||||
name="test",
|
||||
content="content",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
repository.create(artifact)
|
||||
retrieved = repository.get_by_id(artifact.id)
|
||||
|
||||
assert retrieved.metadata.description == "Test description"
|
||||
assert retrieved.metadata.tags == ["tag1", "tag2"]
|
||||
assert retrieved.metadata.author == "test-author"
|
||||
assert retrieved.metadata.version == "1.0.0"
|
||||
assert retrieved.metadata.custom == {"key": "value"}
|
||||
Reference in New Issue
Block a user