"""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"])