""" 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 tags for highlighting excerpt = data["results"][0]["excerpt"] assert "" 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