Complete user profile management system with CRUD operations and CLI integration: ## 🎯 Core Features Delivered - **ProfileManager**: Complete CRUD operations with database integration - **JSON Schema validation**: Comprehensive profile data validation - **Multiple profile support**: Named profiles (personal, work, etc.) - **Default profile system**: Set and manage default profiles - **Profile inheritance**: Merge profiles with override capabilities - **Template integration**: Extract flattened variables for template filling ## 📋 Profile Schema & Data Model - **Structured data classes**: ProfileData, ContactInfo, Address, Organization - **JSON Schema validation**: Full validation with field descriptions - **Flexible structure**: Support for nested data and custom fields - **Timestamp management**: Automatic created_at/updated_at tracking ## 🖥️ CLI Integration Complete - **9 CLI Commands**: create, show, list, update, delete, set-default, export, import, variables - **Multiple formats**: JSON, YAML, and table output formats - **Interactive mode**: Guided profile creation and updates - **Export/Import**: Full profile portability with validation - **Template variables**: Extract flattened variables for template systems ## 📊 Implementation Stats - **ProfileManager**: 500+ lines with comprehensive functionality - **ProfileSchema**: 350+ lines with validation and data structures - **CLI Commands**: 450+ lines of professional command interface - **Test Coverage**: 66 tests (36 core + 30 CLI) with 100% pass rate ## 🚀 **Ready for Template Integration** Foundation complete for Issue #99 (Auto Fill Templates) with: - Template variable extraction from profiles - Default profile system for seamless integration - Profile merging for complex template scenarios - Professional CLI for user profile management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
568 lines
22 KiB
Python
568 lines
22 KiB
Python
"""
|
|
Tests for MarkiTect User Profile Management System.
|
|
|
|
This module tests the complete user profile management functionality including:
|
|
- CRUD operations for user profiles
|
|
- Profile validation and schema compliance
|
|
- Database integration and data persistence
|
|
- Profile inheritance and merging
|
|
- Template variable extraction
|
|
- Export/import functionality
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from markitect.profile.manager import ProfileManager, ProfileNotFoundError, ProfileValidationError
|
|
from markitect.profile.schema import ProfileSchema, ProfileData, ContactInfo, Address, Organization
|
|
|
|
|
|
class TestProfileSchema:
|
|
"""Test suite for profile schema and validation."""
|
|
|
|
def test_profile_data_creation(self):
|
|
"""Test ProfileData dataclass creation."""
|
|
profile = ProfileData(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
contact=ContactInfo(email="john@example.com"),
|
|
address=Address(city="Boston", country="USA")
|
|
)
|
|
|
|
assert profile.first_name == "John"
|
|
assert profile.last_name == "Doe"
|
|
assert profile.contact.email == "john@example.com"
|
|
assert profile.address.city == "Boston"
|
|
|
|
def test_profile_data_to_dict(self):
|
|
"""Test converting ProfileData to dictionary."""
|
|
profile = ProfileData(
|
|
first_name="Jane",
|
|
last_name="Smith",
|
|
contact=ContactInfo(email="jane@example.com", phone="123-456-7890")
|
|
)
|
|
|
|
profile_dict = profile.to_dict()
|
|
|
|
assert profile_dict["first_name"] == "Jane"
|
|
assert profile_dict["last_name"] == "Smith"
|
|
assert profile_dict["contact"]["email"] == "jane@example.com"
|
|
assert profile_dict["contact"]["phone"] == "123-456-7890"
|
|
|
|
def test_profile_data_from_dict(self):
|
|
"""Test creating ProfileData from dictionary."""
|
|
data = {
|
|
"first_name": "Bob",
|
|
"last_name": "Johnson",
|
|
"contact": {
|
|
"email": "bob@example.com",
|
|
"phone": "098-765-4321"
|
|
},
|
|
"organization": {
|
|
"name": "ACME Corp",
|
|
"position": "Developer"
|
|
}
|
|
}
|
|
|
|
profile = ProfileData.from_dict(data)
|
|
|
|
assert profile.first_name == "Bob"
|
|
assert profile.contact.email == "bob@example.com"
|
|
assert profile.organization.name == "ACME Corp"
|
|
|
|
def test_profile_schema_validation_success(self):
|
|
"""Test successful profile schema validation."""
|
|
valid_data = {
|
|
"first_name": "Alice",
|
|
"last_name": "Wilson",
|
|
"contact": {
|
|
"email": "alice@example.com"
|
|
},
|
|
"address": {
|
|
"city": "New York",
|
|
"country": "USA"
|
|
}
|
|
}
|
|
|
|
# Should not raise exception
|
|
ProfileSchema.validate(valid_data)
|
|
assert ProfileSchema.is_valid(valid_data) is True
|
|
|
|
def test_profile_schema_validation_failure(self):
|
|
"""Test profile schema validation with invalid data."""
|
|
invalid_data = {
|
|
"first_name": "A" * 150, # Too long
|
|
"contact": {
|
|
"email": "invalid-email" # Invalid email format
|
|
}
|
|
}
|
|
|
|
with pytest.raises(Exception): # ValidationError
|
|
ProfileSchema.validate(invalid_data)
|
|
|
|
assert ProfileSchema.is_valid(invalid_data) is False
|
|
|
|
def test_profile_schema_get_field_description(self):
|
|
"""Test getting field descriptions from schema."""
|
|
email_desc = ProfileSchema.get_field_description("contact.email")
|
|
assert "Email address" in email_desc
|
|
|
|
name_desc = ProfileSchema.get_field_description("first_name")
|
|
assert "first name" in name_desc.lower()
|
|
|
|
invalid_desc = ProfileSchema.get_field_description("nonexistent.field")
|
|
assert invalid_desc is None
|
|
|
|
def test_profile_schema_get_all_fields(self):
|
|
"""Test getting all available field paths."""
|
|
fields = ProfileSchema.get_all_fields()
|
|
|
|
assert "first_name" in fields
|
|
assert "contact.email" in fields
|
|
assert "organization.name" in fields
|
|
assert "address.city" in fields
|
|
assert len(fields) > 10 # Should have many fields
|
|
|
|
def test_create_empty_profile(self):
|
|
"""Test creating empty profile with timestamps."""
|
|
profile = ProfileSchema.create_empty_profile()
|
|
|
|
assert profile.first_name is None
|
|
assert profile.contact is not None
|
|
assert profile.created_at is not None
|
|
assert profile.updated_at is not None
|
|
|
|
|
|
class TestProfileManager:
|
|
"""Test suite for profile manager functionality."""
|
|
|
|
@pytest.fixture
|
|
def temp_db(self):
|
|
"""Create temporary database for testing."""
|
|
fd, path = tempfile.mkstemp(suffix='.db')
|
|
os.close(fd)
|
|
yield path
|
|
os.unlink(path)
|
|
|
|
@pytest.fixture
|
|
def profile_manager(self, temp_db):
|
|
"""Create profile manager with temporary database."""
|
|
return ProfileManager(temp_db)
|
|
|
|
@pytest.fixture
|
|
def sample_profile_data(self):
|
|
"""Sample profile data for testing."""
|
|
return ProfileData(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
full_name="John Doe",
|
|
contact=ContactInfo(
|
|
email="john.doe@example.com",
|
|
phone="555-0123"
|
|
),
|
|
organization=Organization(
|
|
name="Tech Corp",
|
|
position="Senior Developer"
|
|
),
|
|
address=Address(
|
|
city="San Francisco",
|
|
state="CA",
|
|
country="USA"
|
|
)
|
|
)
|
|
|
|
def test_create_profile_success(self, profile_manager, sample_profile_data):
|
|
"""Test successful profile creation."""
|
|
profile_id = profile_manager.create_profile(
|
|
name="personal",
|
|
data=sample_profile_data,
|
|
description="My personal profile"
|
|
)
|
|
|
|
assert profile_id is not None
|
|
assert isinstance(profile_id, int)
|
|
|
|
# Verify profile was created
|
|
retrieved_profile = profile_manager.get_profile("personal")
|
|
assert retrieved_profile.first_name == "John"
|
|
assert retrieved_profile.last_name == "Doe"
|
|
assert retrieved_profile.contact.email == "john.doe@example.com"
|
|
|
|
def test_create_profile_with_dict(self, profile_manager):
|
|
"""Test profile creation with dictionary data."""
|
|
profile_data = {
|
|
"first_name": "Jane",
|
|
"last_name": "Smith",
|
|
"contact": {
|
|
"email": "jane@example.com"
|
|
}
|
|
}
|
|
|
|
profile_id = profile_manager.create_profile("work", profile_data)
|
|
assert profile_id is not None
|
|
|
|
retrieved_profile = profile_manager.get_profile("work")
|
|
assert retrieved_profile.first_name == "Jane"
|
|
|
|
def test_create_profile_duplicate_name(self, profile_manager, sample_profile_data):
|
|
"""Test creating profile with duplicate name fails."""
|
|
profile_manager.create_profile("test", sample_profile_data)
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
profile_manager.create_profile("test", sample_profile_data)
|
|
|
|
def test_create_profile_invalid_data(self, profile_manager):
|
|
"""Test creating profile with invalid data fails."""
|
|
invalid_data = {
|
|
"first_name": "A" * 150, # Too long
|
|
"contact": {
|
|
"email": "invalid-email"
|
|
}
|
|
}
|
|
|
|
with pytest.raises(ProfileValidationError):
|
|
profile_manager.create_profile("invalid", invalid_data)
|
|
|
|
def test_create_profile_set_default(self, profile_manager, sample_profile_data):
|
|
"""Test creating profile and setting as default."""
|
|
profile_id = profile_manager.create_profile(
|
|
"default_test",
|
|
sample_profile_data,
|
|
set_as_default=True
|
|
)
|
|
|
|
# Verify it's set as default
|
|
default_profile = profile_manager.get_default_profile()
|
|
assert default_profile is not None
|
|
assert default_profile.first_name == "John"
|
|
|
|
def test_get_profile_not_found(self, profile_manager):
|
|
"""Test getting non-existent profile raises error."""
|
|
with pytest.raises(ProfileNotFoundError):
|
|
profile_manager.get_profile("nonexistent")
|
|
|
|
def test_get_profile_info(self, profile_manager, sample_profile_data):
|
|
"""Test getting profile metadata."""
|
|
profile_manager.create_profile("info_test", sample_profile_data, description="Test profile")
|
|
|
|
profile_info = profile_manager.get_profile_info("info_test")
|
|
|
|
assert profile_info["name"] == "info_test"
|
|
assert profile_info["description"] == "Test profile"
|
|
assert profile_info["is_active"] is True
|
|
assert profile_info["is_default"] is False
|
|
assert "created_at" in profile_info
|
|
assert "updated_at" in profile_info
|
|
|
|
def test_update_profile_success(self, profile_manager, sample_profile_data):
|
|
"""Test successful profile update."""
|
|
profile_manager.create_profile("update_test", sample_profile_data)
|
|
|
|
# Update some fields
|
|
updated_data = ProfileData(
|
|
first_name="Johnny",
|
|
last_name="Doe",
|
|
contact=ContactInfo(email="johnny@example.com")
|
|
)
|
|
|
|
success = profile_manager.update_profile("update_test", updated_data, "Updated description")
|
|
assert success is True
|
|
|
|
# Verify updates
|
|
updated_profile = profile_manager.get_profile("update_test")
|
|
assert updated_profile.first_name == "Johnny"
|
|
assert updated_profile.contact.email == "johnny@example.com"
|
|
|
|
profile_info = profile_manager.get_profile_info("update_test")
|
|
assert profile_info["description"] == "Updated description"
|
|
|
|
def test_update_profile_not_found(self, profile_manager):
|
|
"""Test updating non-existent profile fails."""
|
|
with pytest.raises(ProfileNotFoundError):
|
|
profile_manager.update_profile("nonexistent", ProfileData())
|
|
|
|
def test_delete_profile_soft_delete(self, profile_manager, sample_profile_data):
|
|
"""Test soft delete (deactivate) profile."""
|
|
profile_manager.create_profile("delete_test", sample_profile_data)
|
|
|
|
success = profile_manager.delete_profile("delete_test", hard_delete=False)
|
|
assert success is True
|
|
|
|
# Profile should not be found in active profiles
|
|
with pytest.raises(ProfileNotFoundError):
|
|
profile_manager.get_profile("delete_test")
|
|
|
|
# But should appear in list with inactive profiles
|
|
all_profiles = profile_manager.list_profiles(include_inactive=True)
|
|
inactive_names = [p["name"] for p in all_profiles if not p["is_active"]]
|
|
assert "delete_test" in inactive_names
|
|
|
|
def test_delete_profile_hard_delete(self, profile_manager, sample_profile_data):
|
|
"""Test hard delete (permanent) profile."""
|
|
profile_manager.create_profile("hard_delete_test", sample_profile_data)
|
|
|
|
success = profile_manager.delete_profile("hard_delete_test", hard_delete=True)
|
|
assert success is True
|
|
|
|
# Profile should not appear anywhere
|
|
all_profiles = profile_manager.list_profiles(include_inactive=True)
|
|
all_names = [p["name"] for p in all_profiles]
|
|
assert "hard_delete_test" not in all_names
|
|
|
|
def test_list_profiles_active_only(self, profile_manager, sample_profile_data):
|
|
"""Test listing active profiles only."""
|
|
# Create multiple profiles
|
|
profile_manager.create_profile("active1", sample_profile_data)
|
|
profile_manager.create_profile("active2", sample_profile_data)
|
|
profile_manager.create_profile("to_deactivate", sample_profile_data)
|
|
|
|
# Deactivate one
|
|
profile_manager.delete_profile("to_deactivate", hard_delete=False)
|
|
|
|
profiles = profile_manager.list_profiles(include_inactive=False)
|
|
active_names = [p["name"] for p in profiles]
|
|
|
|
assert "active1" in active_names
|
|
assert "active2" in active_names
|
|
assert "to_deactivate" not in active_names
|
|
|
|
def test_list_profiles_include_inactive(self, profile_manager, sample_profile_data):
|
|
"""Test listing all profiles including inactive."""
|
|
profile_manager.create_profile("active", sample_profile_data)
|
|
profile_manager.create_profile("inactive", sample_profile_data)
|
|
profile_manager.delete_profile("inactive", hard_delete=False)
|
|
|
|
profiles = profile_manager.list_profiles(include_inactive=True)
|
|
all_names = [p["name"] for p in profiles]
|
|
|
|
assert "active" in all_names
|
|
assert "inactive" in all_names
|
|
assert len(profiles) == 2
|
|
|
|
def test_set_default_profile(self, profile_manager, sample_profile_data):
|
|
"""Test setting default profile."""
|
|
# Create multiple profiles
|
|
profile_manager.create_profile("profile1", sample_profile_data)
|
|
profile_manager.create_profile("profile2", sample_profile_data)
|
|
|
|
# Set profile2 as default
|
|
success = profile_manager.set_default_profile("profile2")
|
|
assert success is True
|
|
|
|
# Verify default
|
|
default_profile = profile_manager.get_default_profile()
|
|
assert default_profile is not None
|
|
assert default_profile.first_name == "John" # From sample data
|
|
|
|
# Check that only profile2 is marked as default
|
|
profiles = profile_manager.list_profiles()
|
|
default_profiles = [p for p in profiles if p["is_default"]]
|
|
assert len(default_profiles) == 1
|
|
assert default_profiles[0]["name"] == "profile2"
|
|
|
|
def test_get_default_profile_none_set(self, profile_manager):
|
|
"""Test getting default profile when none is set."""
|
|
default_profile = profile_manager.get_default_profile()
|
|
assert default_profile is None
|
|
|
|
def test_export_profile_json(self, profile_manager, sample_profile_data):
|
|
"""Test exporting profile to JSON format."""
|
|
profile_manager.create_profile("export_test", sample_profile_data, "Test for export")
|
|
|
|
exported = profile_manager.export_profile("export_test", format="json")
|
|
|
|
# Parse and verify
|
|
data = json.loads(exported)
|
|
assert "profile_info" in data
|
|
assert "profile_data" in data
|
|
assert data["profile_info"]["name"] == "export_test"
|
|
assert data["profile_data"]["first_name"] == "John"
|
|
|
|
def test_export_profile_yaml(self, profile_manager, sample_profile_data):
|
|
"""Test exporting profile to YAML format."""
|
|
profile_manager.create_profile("yaml_test", sample_profile_data)
|
|
|
|
try:
|
|
exported = profile_manager.export_profile("yaml_test", format="yaml")
|
|
assert "first_name: John" in exported
|
|
assert "profile_info:" in exported
|
|
except ValueError as e:
|
|
if "PyYAML" in str(e):
|
|
pytest.skip("PyYAML not available")
|
|
raise
|
|
|
|
def test_export_profile_unsupported_format(self, profile_manager, sample_profile_data):
|
|
"""Test exporting profile with unsupported format."""
|
|
profile_manager.create_profile("format_test", sample_profile_data)
|
|
|
|
with pytest.raises(ValueError, match="Unsupported export format"):
|
|
profile_manager.export_profile("format_test", format="xml")
|
|
|
|
def test_import_profile_json(self, profile_manager):
|
|
"""Test importing profile from JSON."""
|
|
import_data = {
|
|
"profile_info": {
|
|
"description": "Imported profile"
|
|
},
|
|
"profile_data": {
|
|
"first_name": "Imported",
|
|
"last_name": "User",
|
|
"contact": {
|
|
"email": "imported@example.com"
|
|
}
|
|
}
|
|
}
|
|
|
|
json_data = json.dumps(import_data)
|
|
profile_id = profile_manager.import_profile("imported", json_data, format="json")
|
|
|
|
assert profile_id is not None
|
|
|
|
# Verify imported data
|
|
imported_profile = profile_manager.get_profile("imported")
|
|
assert imported_profile.first_name == "Imported"
|
|
assert imported_profile.contact.email == "imported@example.com"
|
|
|
|
profile_info = profile_manager.get_profile_info("imported")
|
|
assert profile_info["description"] == "Imported profile"
|
|
|
|
def test_import_profile_overwrite(self, profile_manager, sample_profile_data):
|
|
"""Test importing profile with overwrite."""
|
|
# Create existing profile
|
|
profile_manager.create_profile("overwrite_test", sample_profile_data)
|
|
|
|
# Import new data
|
|
import_data = {
|
|
"profile_data": {
|
|
"first_name": "Overwritten",
|
|
"contact": {"email": "new@example.com"}
|
|
}
|
|
}
|
|
|
|
json_data = json.dumps(import_data)
|
|
profile_id = profile_manager.import_profile("overwrite_test", json_data, overwrite=True)
|
|
|
|
# Verify overwrite
|
|
profile = profile_manager.get_profile("overwrite_test")
|
|
assert profile.first_name == "Overwritten"
|
|
|
|
def test_import_profile_no_overwrite_fails(self, profile_manager, sample_profile_data):
|
|
"""Test importing existing profile without overwrite fails."""
|
|
profile_manager.create_profile("existing", sample_profile_data)
|
|
|
|
import_data = {"profile_data": {"first_name": "New"}}
|
|
json_data = json.dumps(import_data)
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
profile_manager.import_profile("existing", json_data, overwrite=False)
|
|
|
|
def test_merge_profiles(self, profile_manager):
|
|
"""Test merging two profiles."""
|
|
# Create base profile
|
|
base_data = ProfileData(
|
|
first_name="Base",
|
|
last_name="User",
|
|
contact=ContactInfo(email="base@example.com", phone="123-456-7890"),
|
|
address=Address(city="BaseCity")
|
|
)
|
|
profile_manager.create_profile("base", base_data)
|
|
|
|
# Create override profile
|
|
override_data = ProfileData(
|
|
first_name="Override",
|
|
contact=ContactInfo(email="override@example.com"),
|
|
organization=Organization(name="Override Corp")
|
|
)
|
|
profile_manager.create_profile("override", override_data)
|
|
|
|
# Merge profiles
|
|
merged_profile = profile_manager.merge_profiles("base", "override")
|
|
|
|
# Verify merge results
|
|
assert merged_profile.first_name == "Override" # Overridden
|
|
assert merged_profile.last_name == "User" # From base
|
|
assert merged_profile.contact.email == "override@example.com" # Overridden
|
|
assert merged_profile.contact.phone == "123-456-7890" # From base
|
|
assert merged_profile.address.city == "BaseCity" # From base
|
|
assert merged_profile.organization.name == "Override Corp" # From override
|
|
|
|
def test_get_template_variables(self, profile_manager, sample_profile_data):
|
|
"""Test extracting template variables from profile."""
|
|
profile_manager.create_profile("template_test", sample_profile_data, set_as_default=True)
|
|
|
|
variables = profile_manager.get_template_variables("template_test")
|
|
|
|
# Check flattened variables
|
|
assert variables["first_name"] == "John"
|
|
assert variables["last_name"] == "Doe"
|
|
assert variables["contact.email"] == "john.doe@example.com"
|
|
assert variables["organization.name"] == "Tech Corp"
|
|
assert variables["address.city"] == "San Francisco"
|
|
|
|
# Check computed variable
|
|
assert variables["full_name"] == "John Doe"
|
|
|
|
def test_get_template_variables_default_profile(self, profile_manager, sample_profile_data):
|
|
"""Test getting template variables from default profile."""
|
|
profile_manager.create_profile("default_vars", sample_profile_data, set_as_default=True)
|
|
|
|
# Get variables without specifying profile name
|
|
variables = profile_manager.get_template_variables()
|
|
|
|
assert variables["first_name"] == "John"
|
|
assert "contact.email" in variables
|
|
|
|
def test_get_template_variables_no_default(self, profile_manager):
|
|
"""Test getting template variables when no default profile."""
|
|
variables = profile_manager.get_template_variables()
|
|
assert variables == {}
|
|
|
|
def test_database_integration(self, profile_manager, sample_profile_data):
|
|
"""Test database persistence and retrieval."""
|
|
# Create profile
|
|
profile_id = profile_manager.create_profile("db_test", sample_profile_data)
|
|
|
|
# Create new manager instance with same database
|
|
new_manager = ProfileManager(profile_manager.db_path)
|
|
|
|
# Verify data persists
|
|
retrieved_profile = new_manager.get_profile("db_test")
|
|
assert retrieved_profile.first_name == "John"
|
|
assert retrieved_profile.contact.email == "john.doe@example.com"
|
|
|
|
def test_profile_timestamps(self, profile_manager, sample_profile_data):
|
|
"""Test profile creation and update timestamps."""
|
|
before_create = datetime.now().isoformat()
|
|
profile_manager.create_profile("timestamp_test", sample_profile_data)
|
|
after_create = datetime.now().isoformat()
|
|
|
|
profile_info = profile_manager.get_profile_info("timestamp_test")
|
|
assert before_create <= profile_info["created_at"] <= after_create
|
|
assert before_create <= profile_info["updated_at"] <= after_create
|
|
|
|
# Update profile
|
|
before_update = datetime.now().isoformat()
|
|
profile_manager.update_profile("timestamp_test", ProfileData(first_name="Updated"))
|
|
after_update = datetime.now().isoformat()
|
|
|
|
updated_info = profile_manager.get_profile_info("timestamp_test")
|
|
assert updated_info["created_at"] == profile_info["created_at"] # Unchanged
|
|
assert before_update <= updated_info["updated_at"] <= after_update
|
|
|
|
def test_edge_cases(self, profile_manager):
|
|
"""Test edge cases and boundary conditions."""
|
|
# Empty profile
|
|
empty_profile = ProfileData()
|
|
profile_manager.create_profile("empty", empty_profile)
|
|
retrieved = profile_manager.get_profile("empty")
|
|
assert retrieved.first_name is None
|
|
|
|
# Profile with only custom fields
|
|
custom_profile = ProfileData(custom_fields={"hobby": "coding", "level": "expert"})
|
|
profile_manager.create_profile("custom", custom_profile)
|
|
retrieved_custom = profile_manager.get_profile("custom")
|
|
assert retrieved_custom.custom_fields["hobby"] == "coding" |