Implements full search functionality for StarPunk v1.1.0. Search API Endpoint (/api/search): - GET endpoint with query parameter (q) validation - Pagination via limit (default 20, max 100) and offset parameters - JSON response with results count and formatted search results - Authentication-aware: anonymous users see published notes only - Graceful handling of FTS5 unavailability (503 error) - Proper error responses for missing/empty queries Search Web Interface (/search): - HTML search results page with Bootstrap-inspired styling - Search form with HTML5 validation (minlength=2, maxlength=100) - Results display with title, excerpt, date, and links - Empty state for no results - Error state for FTS5 unavailability - Simple pagination (Next/Previous navigation) Navigation Integration: - Added search box to site navigation in base.html - Preserves query parameter on results page - Responsive design with emoji search icon - Accessible with proper ARIA labels FTS Index Population: - Added startup check in __init__.py for empty FTS index - Automatic rebuild from existing notes on first run - Graceful degradation if population fails - Logging for troubleshooting Security Features: - XSS prevention: HTML in search results properly escaped - Safe highlighting: FTS5 <mark> tags preserved, user content escaped - Query validation: empty queries rejected, length limits enforced - SQL injection prevention via FTS5 query parser - Authentication filtering: unpublished notes hidden from anonymous users Testing: - Added 41 comprehensive tests across 3 test files - test_search_api.py: 12 tests for API endpoint validation - test_search_integration.py: 17 tests for UI rendering and integration - test_search_security.py: 12 tests for XSS, SQL injection, auth filtering - All tests passing with no regressions Implementation follows architect specifications from: - docs/architecture/v1.1.0-validation-report.md - docs/architecture/v1.1.0-feature-architecture.md - docs/decisions/ADR-034-full-text-search.md Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
7.5 KiB
Python
244 lines
7.5 KiB
Python
"""
|
|
Tests for search API endpoint
|
|
|
|
Tests cover:
|
|
- Search API parameter validation
|
|
- Search result formatting
|
|
- Pagination with limit and offset
|
|
- Authentication-based filtering (published/unpublished)
|
|
- FTS5 availability handling
|
|
- Error cases and edge cases
|
|
"""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
from starpunk import create_app
|
|
from starpunk.notes import create_note
|
|
|
|
|
|
@pytest.fixture
|
|
def app(tmp_path):
|
|
"""Create test application with FTS5 enabled"""
|
|
test_data_dir = tmp_path / "data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret-key",
|
|
"ADMIN_ME": "https://test.example.com",
|
|
"SITE_URL": "https://example.com",
|
|
"SITE_NAME": "Test Blog",
|
|
"DEV_MODE": False,
|
|
}
|
|
app = create_app(config=test_config)
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Create test client"""
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_notes(app):
|
|
"""Create test notes for searching"""
|
|
with app.app_context():
|
|
notes = []
|
|
|
|
# Published notes
|
|
note1 = create_note(
|
|
content="# Python Tutorial\n\nLearn Python programming with examples.",
|
|
published=True
|
|
)
|
|
notes.append(note1)
|
|
|
|
note2 = create_note(
|
|
content="# JavaScript Guide\n\nModern JavaScript best practices.",
|
|
published=True
|
|
)
|
|
notes.append(note2)
|
|
|
|
note3 = create_note(
|
|
content="# Python Testing\n\nHow to write tests in Python using pytest.",
|
|
published=True
|
|
)
|
|
notes.append(note3)
|
|
|
|
# Unpublished note
|
|
note4 = create_note(
|
|
content="# Draft Python Article\n\nThis is unpublished.",
|
|
published=False
|
|
)
|
|
notes.append(note4)
|
|
|
|
return notes
|
|
|
|
|
|
def test_search_api_requires_query(client):
|
|
"""Test that search API requires a query parameter"""
|
|
response = client.get("/api/search")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
assert "Missing required parameter" in data["error"]
|
|
|
|
|
|
def test_search_api_rejects_empty_query(client):
|
|
"""Test that search API rejects empty query"""
|
|
response = client.get("/api/search?q=")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
|
|
|
|
def test_search_api_returns_results(client, test_notes):
|
|
"""Test that search API returns matching results"""
|
|
response = client.get("/api/search?q=python")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
assert data["query"] == "python"
|
|
assert data["count"] >= 2 # Should match at least 2 Python notes
|
|
assert len(data["results"]) >= 2
|
|
|
|
# Check result structure
|
|
result = data["results"][0]
|
|
assert "slug" in result
|
|
assert "title" in result
|
|
assert "excerpt" in result
|
|
assert "published_at" in result
|
|
assert "url" in result
|
|
|
|
|
|
def test_search_api_returns_no_results_for_nonexistent(client, test_notes):
|
|
"""Test that search API returns empty results for non-matching query"""
|
|
response = client.get("/api/search?q=nonexistent")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
assert data["query"] == "nonexistent"
|
|
assert data["count"] == 0
|
|
assert len(data["results"]) == 0
|
|
|
|
|
|
def test_search_api_validates_limit(client, test_notes):
|
|
"""Test that search API validates and applies limit parameter"""
|
|
# Test valid limit
|
|
response = client.get("/api/search?q=python&limit=1")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["limit"] == 1
|
|
assert len(data["results"]) <= 1
|
|
|
|
# Test max limit (100)
|
|
response = client.get("/api/search?q=python&limit=1000")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["limit"] == 100 # Should be capped at 100
|
|
|
|
# Test invalid limit (defaults to 20)
|
|
response = client.get("/api/search?q=python&limit=invalid")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["limit"] == 20
|
|
|
|
|
|
def test_search_api_validates_offset(client, test_notes):
|
|
"""Test that search API validates offset parameter"""
|
|
response = client.get("/api/search?q=python&offset=1")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["offset"] == 1
|
|
|
|
# Test invalid offset (defaults to 0)
|
|
response = client.get("/api/search?q=python&offset=-5")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["offset"] == 0
|
|
|
|
|
|
def test_search_api_pagination(client, test_notes):
|
|
"""Test that search API pagination works correctly"""
|
|
# Get first page
|
|
response1 = client.get("/api/search?q=python&limit=1&offset=0")
|
|
data1 = response1.get_json()
|
|
|
|
# Get second page
|
|
response2 = client.get("/api/search?q=python&limit=1&offset=1")
|
|
data2 = response2.get_json()
|
|
|
|
# Results should be different (if there are at least 2 matches)
|
|
if data1["count"] > 0 and len(data2["results"]) > 0:
|
|
assert data1["results"][0]["slug"] != data2["results"][0]["slug"]
|
|
|
|
|
|
def test_search_api_respects_published_status(client, test_notes):
|
|
"""Test that anonymous users only see published notes"""
|
|
response = client.get("/api/search?q=draft")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
# Anonymous user should not see unpublished "Draft Python Article"
|
|
assert data["count"] == 0
|
|
|
|
|
|
def test_search_api_highlights_matches(client, test_notes):
|
|
"""Test that search API includes highlighted excerpts"""
|
|
response = client.get("/api/search?q=python")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
if data["count"] > 0:
|
|
# Check that excerpts contain <mark> tags for highlighting
|
|
excerpt = data["results"][0]["excerpt"]
|
|
assert "<mark>" in excerpt or "python" in excerpt.lower()
|
|
|
|
|
|
def test_search_api_handles_special_characters(client, test_notes):
|
|
"""Test that search API handles special characters in query"""
|
|
# Test quotes
|
|
response = client.get('/api/search?q="python"')
|
|
assert response.status_code == 200
|
|
|
|
# Test with URL encoding
|
|
response = client.get("/api/search?q=python%20testing")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["query"] == "python testing"
|
|
|
|
|
|
def test_search_api_generates_correct_urls(client, test_notes):
|
|
"""Test that search API generates correct note URLs"""
|
|
response = client.get("/api/search?q=python")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
if data["count"] > 0:
|
|
result = data["results"][0]
|
|
assert result["url"].startswith("/notes/")
|
|
assert result["url"] == f"/notes/{result['slug']}"
|
|
|
|
|
|
def test_search_api_provides_fallback_title(client, app):
|
|
"""Test that search API provides fallback title for notes without title"""
|
|
with app.app_context():
|
|
# Create note without clear title
|
|
note = create_note(
|
|
content="Just some content without a heading.",
|
|
published=True
|
|
)
|
|
|
|
response = client.get("/api/search?q=content")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
if data["count"] > 0:
|
|
# Should have some title (either extracted or fallback)
|
|
assert data["results"][0]["title"] is not None
|
|
assert len(data["results"][0]["title"]) > 0
|