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>
621 lines
22 KiB
Python
621 lines
22 KiB
Python
"""
|
|
Tests for MarkiTect user profile CLI commands.
|
|
|
|
This module tests the command-line interface for user profile management
|
|
including creation, listing, updating, and template variable extraction.
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
import json
|
|
from click.testing import CliRunner
|
|
from pathlib import Path
|
|
|
|
from markitect.profile.commands import profile_commands
|
|
from markitect.profile.manager import ProfileManager
|
|
from markitect.profile.schema import ProfileData, ContactInfo, Organization
|
|
|
|
|
|
class TestProfileCLICommands:
|
|
"""Test suite for profile management CLI commands."""
|
|
|
|
@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 setup_test_profile(self, temp_db):
|
|
"""Setup test database with a sample profile."""
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile_data = ProfileData(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
contact=ContactInfo(email="john@example.com", phone="555-0123"),
|
|
organization=Organization(name="Tech Corp", position="Developer")
|
|
)
|
|
profile_id = profile_manager.create_profile("test_profile", profile_data, "Test profile")
|
|
return temp_db, profile_id
|
|
|
|
@pytest.fixture
|
|
def runner(self):
|
|
"""Create Click test runner."""
|
|
return CliRunner()
|
|
|
|
def test_profile_create_basic(self, runner, temp_db):
|
|
"""Test basic profile creation."""
|
|
result = runner.invoke(profile_commands, [
|
|
'create', 'basic_profile',
|
|
'--first-name', 'Alice',
|
|
'--last-name', 'Smith',
|
|
'--email', 'alice@example.com',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Created profile 'basic_profile'" in result.output
|
|
|
|
# Verify profile was created
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('basic_profile')
|
|
assert profile.first_name == "Alice"
|
|
assert profile.contact.email == "alice@example.com"
|
|
|
|
def test_profile_create_with_organization(self, runner, temp_db):
|
|
"""Test profile creation with organization info."""
|
|
result = runner.invoke(profile_commands, [
|
|
'create', 'work_profile',
|
|
'--first-name', 'Bob',
|
|
'--organization', 'ACME Corp',
|
|
'--position', 'Manager',
|
|
'--city', 'New York',
|
|
'--description', 'My work profile',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Created profile 'work_profile'" in result.output
|
|
|
|
# Verify organization details
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('work_profile')
|
|
assert profile.organization.name == "ACME Corp"
|
|
assert profile.organization.position == "Manager"
|
|
assert profile.address.city == "New York"
|
|
|
|
def test_profile_create_set_default(self, runner, temp_db):
|
|
"""Test creating profile and setting as default."""
|
|
result = runner.invoke(profile_commands, [
|
|
'create', 'default_profile',
|
|
'--first-name', 'Default',
|
|
'--set-default',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Created profile 'default_profile'" in result.output
|
|
assert "🎯 Set 'default_profile' as default profile" in result.output
|
|
|
|
# Verify it's set as default
|
|
profile_manager = ProfileManager(temp_db)
|
|
default_profile = profile_manager.get_default_profile()
|
|
assert default_profile.first_name == "Default"
|
|
|
|
def test_profile_create_duplicate_name(self, runner, setup_test_profile):
|
|
"""Test creating profile with duplicate name fails."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'create', 'test_profile', # Same name as existing
|
|
'--first-name', 'Duplicate',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "already exists" in result.output
|
|
|
|
def test_profile_show_table_format(self, runner, setup_test_profile):
|
|
"""Test showing profile in table format."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'show', 'test_profile',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "👤 Profile: test_profile" in result.output
|
|
assert "First Name: John" in result.output
|
|
assert "📞 Contact Information" in result.output
|
|
assert "Email: john@example.com" in result.output
|
|
assert "🏢 Organization" in result.output
|
|
assert "Organization: Tech Corp" in result.output
|
|
|
|
def test_profile_show_json_format(self, runner, setup_test_profile):
|
|
"""Test showing profile in JSON format."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'show', 'test_profile',
|
|
'--format', 'json',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
# Parse JSON output
|
|
data = json.loads(result.output)
|
|
assert "profile_info" in data
|
|
assert "profile_data" in data
|
|
assert data["profile_info"]["name"] == "test_profile"
|
|
assert data["profile_data"]["first_name"] == "John"
|
|
|
|
def test_profile_show_nonexistent(self, runner, temp_db):
|
|
"""Test showing non-existent profile."""
|
|
result = runner.invoke(profile_commands, [
|
|
'show', 'nonexistent',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "not found" in result.output
|
|
|
|
def test_profile_list_empty(self, runner, temp_db):
|
|
"""Test listing profiles when none exist."""
|
|
result = runner.invoke(profile_commands, [
|
|
'list',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No profiles found" in result.output
|
|
|
|
def test_profile_list_with_profiles(self, runner, temp_db):
|
|
"""Test listing profiles."""
|
|
# Create multiple profiles
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile_manager.create_profile("profile1", ProfileData(first_name="User1"), "First profile")
|
|
profile_manager.create_profile("profile2", ProfileData(first_name="User2"), set_as_default=True)
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'list',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "👤 User Profiles" in result.output
|
|
assert "profile1" in result.output
|
|
assert "profile2" in result.output
|
|
assert "Total: 2 profiles" in result.output
|
|
|
|
# Check default indicator
|
|
lines = result.output.split('\n')
|
|
profile2_line = [line for line in lines if 'profile2' in line][0]
|
|
assert "Yes" in profile2_line # Default column should show "Yes"
|
|
|
|
def test_profile_list_include_inactive(self, runner, temp_db):
|
|
"""Test listing profiles including inactive ones."""
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile_manager.create_profile("active", ProfileData(first_name="Active"))
|
|
profile_manager.create_profile("inactive", ProfileData(first_name="Inactive"))
|
|
profile_manager.delete_profile("inactive", hard_delete=False) # Soft delete
|
|
|
|
# List without inactive
|
|
result = runner.invoke(profile_commands, [
|
|
'list',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "active" in result.output
|
|
assert "inactive" not in result.output
|
|
|
|
# List with inactive
|
|
result = runner.invoke(profile_commands, [
|
|
'list',
|
|
'--include-inactive',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "active" in result.output
|
|
assert "inactive" in result.output
|
|
|
|
def test_profile_list_json_format(self, runner, setup_test_profile):
|
|
"""Test listing profiles in JSON format."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'list',
|
|
'--format', 'json',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
# Parse JSON output
|
|
data = json.loads(result.output)
|
|
assert isinstance(data, list)
|
|
assert len(data) == 1
|
|
assert data[0]["name"] == "test_profile"
|
|
|
|
def test_profile_update_basic_fields(self, runner, setup_test_profile):
|
|
"""Test updating basic profile fields."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'update', 'test_profile',
|
|
'--first-name', 'Johnny',
|
|
'--email', 'johnny@example.com',
|
|
'--organization', 'New Corp',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Updated profile 'test_profile'" in result.output
|
|
|
|
# Verify updates
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('test_profile')
|
|
assert profile.first_name == "Johnny"
|
|
assert profile.contact.email == "johnny@example.com"
|
|
assert profile.organization.name == "New Corp"
|
|
|
|
def test_profile_update_nonexistent(self, runner, temp_db):
|
|
"""Test updating non-existent profile."""
|
|
result = runner.invoke(profile_commands, [
|
|
'update', 'nonexistent',
|
|
'--first-name', 'New',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "not found" in result.output
|
|
|
|
def test_profile_delete_soft(self, runner, setup_test_profile):
|
|
"""Test soft delete (deactivate) profile."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'delete', 'test_profile',
|
|
'--yes', # Skip confirmation
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Profile 'test_profile' deactivated" in result.output
|
|
|
|
# Verify profile is deactivated
|
|
profile_manager = ProfileManager(temp_db)
|
|
with pytest.raises(Exception): # ProfileNotFoundError
|
|
profile_manager.get_profile('test_profile')
|
|
|
|
def test_profile_delete_hard(self, runner, setup_test_profile):
|
|
"""Test hard delete (permanent) profile."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'delete', 'test_profile',
|
|
'--hard',
|
|
'--yes',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Profile 'test_profile' deleted permanently" in result.output
|
|
|
|
# Verify profile is completely gone
|
|
profile_manager = ProfileManager(temp_db)
|
|
all_profiles = profile_manager.list_profiles(include_inactive=True)
|
|
profile_names = [p["name"] for p in all_profiles]
|
|
assert "test_profile" not in profile_names
|
|
|
|
def test_profile_delete_with_confirmation(self, runner, setup_test_profile):
|
|
"""Test profile deletion with confirmation prompt."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
# Test declining confirmation
|
|
result = runner.invoke(profile_commands, [
|
|
'delete', 'test_profile',
|
|
'--database', temp_db
|
|
], input='n\n')
|
|
|
|
assert result.exit_code == 0
|
|
assert "Deletion cancelled" in result.output
|
|
|
|
# Profile should still exist
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('test_profile')
|
|
assert profile.first_name == "John"
|
|
|
|
def test_profile_set_default(self, runner, temp_db):
|
|
"""Test setting profile as default."""
|
|
# Create multiple profiles
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile_manager.create_profile("profile1", ProfileData(first_name="User1"))
|
|
profile_manager.create_profile("profile2", ProfileData(first_name="User2"))
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'set-default', 'profile2',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Set 'profile2' as default profile" in result.output
|
|
|
|
# Verify default was set
|
|
default_profile = profile_manager.get_default_profile()
|
|
assert default_profile.first_name == "User2"
|
|
|
|
def test_profile_set_default_nonexistent(self, runner, temp_db):
|
|
"""Test setting non-existent profile as default."""
|
|
result = runner.invoke(profile_commands, [
|
|
'set-default', 'nonexistent',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "not found" in result.output
|
|
|
|
def test_profile_export_json(self, runner, setup_test_profile):
|
|
"""Test exporting profile to JSON."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'export', 'test_profile',
|
|
'--format', 'json',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
# Parse output as JSON
|
|
data = json.loads(result.output)
|
|
assert "profile_info" in data
|
|
assert "profile_data" in data
|
|
assert data["profile_data"]["first_name"] == "John"
|
|
|
|
def test_profile_export_to_file(self, runner, setup_test_profile):
|
|
"""Test exporting profile to file."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
|
|
output_file = f.name
|
|
|
|
try:
|
|
result = runner.invoke(profile_commands, [
|
|
'export', 'test_profile',
|
|
'--output', output_file,
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert f"✅ Exported profile 'test_profile' to {output_file}" in result.output
|
|
|
|
# Verify file contents
|
|
data = json.loads(Path(output_file).read_text())
|
|
assert data["profile_data"]["first_name"] == "John"
|
|
|
|
finally:
|
|
os.unlink(output_file)
|
|
|
|
def test_profile_import_json(self, runner, temp_db):
|
|
"""Test importing profile from JSON file."""
|
|
import_data = {
|
|
"profile_info": {"description": "Imported profile"},
|
|
"profile_data": {
|
|
"first_name": "Imported",
|
|
"last_name": "User",
|
|
"contact": {"email": "imported@example.com"}
|
|
}
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
|
|
json.dump(import_data, f)
|
|
import_file = f.name
|
|
|
|
try:
|
|
result = runner.invoke(profile_commands, [
|
|
'import', 'imported_profile', import_file,
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Created profile 'imported_profile'" in result.output
|
|
|
|
# Verify imported data
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('imported_profile')
|
|
assert profile.first_name == "Imported"
|
|
assert profile.contact.email == "imported@example.com"
|
|
|
|
finally:
|
|
os.unlink(import_file)
|
|
|
|
def test_profile_import_nonexistent_file(self, runner, temp_db):
|
|
"""Test importing from non-existent file."""
|
|
result = runner.invoke(profile_commands, [
|
|
'import', 'test', '/nonexistent/file.json',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "not found" in result.output
|
|
|
|
def test_profile_import_overwrite(self, runner, setup_test_profile):
|
|
"""Test importing with overwrite flag."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
import_data = {
|
|
"profile_data": {
|
|
"first_name": "Overwritten",
|
|
"contact": {"email": "overwritten@example.com"}
|
|
}
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
|
|
json.dump(import_data, f)
|
|
import_file = f.name
|
|
|
|
try:
|
|
result = runner.invoke(profile_commands, [
|
|
'import', 'test_profile', import_file,
|
|
'--overwrite',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Updated profile 'test_profile'" in result.output
|
|
|
|
# Verify overwrite
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile = profile_manager.get_profile('test_profile')
|
|
assert profile.first_name == "Overwritten"
|
|
|
|
finally:
|
|
os.unlink(import_file)
|
|
|
|
def test_profile_variables_table_format(self, runner, setup_test_profile):
|
|
"""Test showing template variables in table format."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'variables',
|
|
'--profile', 'test_profile',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "📋 Template Variables - test_profile" in result.output
|
|
assert "first_name" in result.output
|
|
assert "John" in result.output
|
|
assert "contact.email" in result.output
|
|
assert "organization.name" in result.output
|
|
|
|
def test_profile_variables_json_format(self, runner, setup_test_profile):
|
|
"""Test showing template variables in JSON format."""
|
|
temp_db, _ = setup_test_profile
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'variables',
|
|
'--profile', 'test_profile',
|
|
'--format', 'json',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
# Parse JSON output
|
|
data = json.loads(result.output)
|
|
assert data["first_name"] == "John"
|
|
assert data["contact.email"] == "john@example.com"
|
|
assert data["organization.name"] == "Tech Corp"
|
|
|
|
def test_profile_variables_default_profile(self, runner, temp_db):
|
|
"""Test showing variables from default profile."""
|
|
# Create and set default profile
|
|
profile_manager = ProfileManager(temp_db)
|
|
profile_data = ProfileData(first_name="Default", last_name="User")
|
|
profile_manager.create_profile("default", profile_data, set_as_default=True)
|
|
|
|
result = runner.invoke(profile_commands, [
|
|
'variables', # No --profile specified, should use default
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "📋 Template Variables - (default)" in result.output
|
|
assert "first_name" in result.output
|
|
assert "Default" in result.output
|
|
|
|
def test_profile_variables_no_default(self, runner, temp_db):
|
|
"""Test showing variables when no default profile set."""
|
|
result = runner.invoke(profile_commands, [
|
|
'variables',
|
|
'--database', temp_db
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No default profile set" in result.output
|
|
|
|
def test_profile_help_commands(self, runner):
|
|
"""Test help output for profile commands."""
|
|
# Test main profile help
|
|
result = runner.invoke(profile_commands, ['--help'])
|
|
assert result.exit_code == 0
|
|
assert "User profile management commands" in result.output
|
|
|
|
# Test create help
|
|
result = runner.invoke(profile_commands, ['create', '--help'])
|
|
assert result.exit_code == 0
|
|
assert "Create a new user profile" in result.output
|
|
|
|
# Test show help
|
|
result = runner.invoke(profile_commands, ['show', '--help'])
|
|
assert result.exit_code == 0
|
|
assert "Show profile details" in result.output
|
|
|
|
def test_profile_commands_missing_database(self, runner):
|
|
"""Test profile commands without database specification."""
|
|
# These should use default config path
|
|
result = runner.invoke(profile_commands, [
|
|
'list'
|
|
])
|
|
|
|
# Should succeed with default database configuration
|
|
assert result.exit_code == 0
|
|
|
|
def test_complex_profile_workflow(self, runner, temp_db):
|
|
"""Test complex workflow with multiple operations."""
|
|
# Create profile
|
|
result = runner.invoke(profile_commands, [
|
|
'create', 'workflow_test',
|
|
'--first-name', 'Workflow',
|
|
'--last-name', 'Test',
|
|
'--email', 'workflow@example.com',
|
|
'--organization', 'Test Corp',
|
|
'--description', 'Workflow test profile',
|
|
'--database', temp_db
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
# Update profile
|
|
result = runner.invoke(profile_commands, [
|
|
'update', 'workflow_test',
|
|
'--first-name', 'Updated',
|
|
'--position', 'Manager',
|
|
'--database', temp_db
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
# Set as default
|
|
result = runner.invoke(profile_commands, [
|
|
'set-default', 'workflow_test',
|
|
'--database', temp_db
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
# Show variables
|
|
result = runner.invoke(profile_commands, [
|
|
'variables',
|
|
'--database', temp_db
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "Updated" in result.output # Updated name
|
|
assert "Manager" in result.output # New position
|
|
|
|
# Export profile
|
|
result = runner.invoke(profile_commands, [
|
|
'export', 'workflow_test',
|
|
'--database', temp_db
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
# Verify export contains updates
|
|
data = json.loads(result.output)
|
|
assert data["profile_data"]["first_name"] == "Updated"
|
|
assert data["profile_data"]["organization"]["position"] == "Manager" |