"""Tests for blog module Phase 4 features.""" import os import sys import pytest import tempfile import shutil from pathlib import Path from datetime import datetime, timezone # Add parent to path sys.path.insert(0, str(Path(__file__).parent.parent)) from blog.storage import init_db, POSTS_DIR, get_db, slugify from blog.service import ( create_post, get_related_posts, search_posts, list_posts, get_post, update_post, publish_post ) from blog.models import CreatePostRequest, UpdatePostRequest @pytest.fixture def temp_blog_dir(): """Create a temporary blog directory for testing.""" temp_dir = tempfile.mkdtemp() original_dir = os.environ.get("BLOG_DATA_DIR") # Set up temp directory os.environ["BLOG_DATA_DIR"] = temp_dir posts_dir = Path(temp_dir) / "posts" posts_dir.mkdir(parents=True, exist_ok=True) # Re-initialize DB import blog.storage as storage_module storage_module.DB_PATH = Path(temp_dir) / "blog.db" storage_module.POSTS_DIR = posts_dir storage_module.DATA_DIR = Path(temp_dir) init_db() yield temp_dir # Cleanup if original_dir: os.environ["BLOG_DATA_DIR"] = original_dir else: del os.environ["BLOG_DATA_DIR"] shutil.rmtree(temp_dir) class TestSearch: """Tests for the FTS5 search endpoint.""" def test_search_empty_query(self, temp_blog_dir): """Search with empty query returns empty results.""" result = search_posts("") assert result.total == 0 assert len(result.results) == 0 def test_search_finds_by_title(self, temp_blog_dir): """Search finds posts by title.""" # Create a post create_post(CreatePostRequest( title="Building Multi Agent Systems", content_md="This is about building agents.", category="engineering", tags=["ai", "agents"], status="published" )) # Search for it result = search_posts("multi agent") assert result.total == 1 assert result.results[0].title == "Building Multi Agent Systems" def test_search_finds_by_excerpt(self, temp_blog_dir): """Search finds posts by excerpt.""" # Create a post with explicit excerpt create_post(CreatePostRequest( title="Some Post", excerpt="Deep dive into machine learning techniques", content_md="Content here.", category="engineering", status="published" )) # Search for excerpt content result = search_posts("machine learning") assert result.total == 1 assert "machine" in result.results[0].excerpt.lower() def test_search_only_published(self, temp_blog_dir): """Search only returns published posts.""" # Create published post create_post(CreatePostRequest( title="Published Post About Python", content_md="Python content.", status="published" )) # Create draft post create_post(CreatePostRequest( title="Draft Post About Python", content_md="More Python content.", status="draft" )) # Search for Python result = search_posts("python") assert result.total == 1 assert result.results[0].title == "Published Post About Python" def test_search_ranking(self, temp_blog_dir): """Search results are ranked by relevance.""" # Create multiple posts create_post(CreatePostRequest( title="FastAPI Tutorial", content_md="Learning FastAPI", status="published" )) create_post(CreatePostRequest( title="Django vs FastAPI", content_md="Comparing frameworks", status="published" )) result = search_posts("FastAPI") assert result.total == 2 # All should have rank values (they may be 0 for some matches) for r in result.results: assert r.rank is not None class TestRelatedPosts: """Tests for the related posts algorithm.""" def test_related_same_category(self, temp_blog_dir): """Posts in same category are related.""" # Create posts in same category create_post(CreatePostRequest( title="Post One", content_md="Content one.", category="engineering", tags=["python"], status="published" )) create_post(CreatePostRequest( title="Post Two", content_md="Content two.", category="engineering", tags=["javascript"], status="published" )) result = get_related_posts("post-one", limit=3) assert result.total == 1 assert result.posts[0].slug == "post-two" def test_related_shared_tags(self, temp_blog_dir): """Posts with shared tags are related.""" # Create posts with shared tags create_post(CreatePostRequest( title="Post Alpha", content_md="Content alpha.", category="engineering", tags=["python", "fastapi"], status="published" )) create_post(CreatePostRequest( title="Post Beta", content_md="Content beta.", category="tutorial", tags=["python", "django"], # Shared "python" tag status="published" )) result = get_related_posts("post-alpha", limit=3) assert result.total == 1 assert result.posts[0].slug == "post-beta" def test_related_ranking(self, temp_blog_dir): """Posts with more shared tags rank higher.""" # Source post create_post(CreatePostRequest( title="Source Post", content_md="Source content.", category="engineering", tags=["python", "fastapi", "async"], status="published" )) # Post with 1 shared tag create_post(CreatePostRequest( title="One Tag Match", content_md="Content.", category="tutorial", tags=["python", "flask"], status="published" )) # Post with 2 shared tags (should rank higher) create_post(CreatePostRequest( title="Two Tag Match", content_md="Content.", category="tutorial", tags=["python", "fastapi", "react"], status="published" )) # Post with same category (2 points) + 1 shared tag (1 point) = 3 points create_post(CreatePostRequest( title="Same Category Match", content_md="Content.", category="engineering", tags=["python", "flask"], status="published" )) result = get_related_posts("source-post", limit=3) assert result.total == 3 # Same category + 1 tag = 3 points should be first assert result.posts[0].slug == "same-category-match" def test_related_excludes_self(self, temp_blog_dir): """Related posts excludes the source post.""" create_post(CreatePostRequest( title="Only Post", content_md="Content.", category="engineering", tags=["python"], status="published" )) result = get_related_posts("only-post", limit=3) assert result.total == 0 def test_related_only_published(self, temp_blog_dir): """Related posts only includes published posts.""" create_post(CreatePostRequest( title="Published Post", content_md="Content.", category="engineering", tags=["python"], status="published" )) create_post(CreatePostRequest( title="Draft Post", content_md="Content.", category="engineering", tags=["python"], status="draft" )) result = get_related_posts("published-post", limit=3) assert result.total == 0 # Draft should not appear def test_related_not_found(self, temp_blog_dir): """Related posts for non-existent post returns empty.""" result = get_related_posts("non-existent", limit=3) assert result.total == 0 assert len(result.posts) == 0 def test_related_respects_limit(self, temp_blog_dir): """Related posts respects the limit parameter.""" # Create 5 posts in same category for i in range(5): create_post(CreatePostRequest( title=f"Post {i}", content_md=f"Content {i}.", category="engineering", status="published" )) result = get_related_posts("post-0", limit=2) assert result.total == 2 assert len(result.posts) == 2 class TestSlugGeneration: """Tests for slug generation.""" def test_slugify_simple(self): """Simple titles become valid slugs.""" assert slugify("Hello World") == "hello-world" def test_slugify_special_chars(self): """Special characters are removed.""" assert slugify("Hello, World! (2024)") == "hello-world-2024" def test_slugify_multiple_spaces(self): """Multiple spaces become single hyphen.""" assert slugify("Hello World") == "hello-world" if __name__ == "__main__": pytest.main([__file__, "-v"])