Files
StarPunk/tests/test_search_api.py
Phil Skentelbery 8f71ff36ec feat(search): Add complete Search UI with API and web interface
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>
2025-11-25 10:34:00 -07:00

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