341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""Integration tests for the /api/v1/search endpoint.
|
|
|
|
Tests run against a real PostgreSQL test database via httpx.AsyncClient.
|
|
SearchService is mocked at the router dependency level so we can test
|
|
endpoint behavior without requiring external embedding API or Qdrant.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from models import (
|
|
ContentType,
|
|
Creator,
|
|
KeyMoment,
|
|
KeyMomentContentType,
|
|
ProcessingStatus,
|
|
SourceVideo,
|
|
TechniquePage,
|
|
)
|
|
|
|
SEARCH_URL = "/api/v1/search"
|
|
|
|
|
|
# ── Seed helpers ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _seed_search_data(db_engine) -> dict:
|
|
"""Seed 2 creators, 3 technique pages, and 5 key moments for search tests.
|
|
|
|
Returns a dict with creator/technique IDs and metadata for assertions.
|
|
"""
|
|
session_factory = async_sessionmaker(
|
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
|
)
|
|
async with session_factory() as session:
|
|
# Creators
|
|
creator1 = Creator(
|
|
name="Mr. Bill",
|
|
slug="mr-bill",
|
|
genres=["Bass music", "Glitch"],
|
|
folder_name="MrBill",
|
|
)
|
|
creator2 = Creator(
|
|
name="KOAN Sound",
|
|
slug="koan-sound",
|
|
genres=["Drum & bass", "Neuro"],
|
|
folder_name="KOANSound",
|
|
)
|
|
session.add_all([creator1, creator2])
|
|
await session.flush()
|
|
|
|
# Videos (needed for key moments FK)
|
|
video1 = SourceVideo(
|
|
creator_id=creator1.id,
|
|
filename="bass-design-101.mp4",
|
|
file_path="MrBill/bass-design-101.mp4",
|
|
duration_seconds=600,
|
|
content_type=ContentType.tutorial,
|
|
processing_status=ProcessingStatus.extracted,
|
|
)
|
|
video2 = SourceVideo(
|
|
creator_id=creator2.id,
|
|
filename="reese-bass-deep-dive.mp4",
|
|
file_path="KOANSound/reese-bass-deep-dive.mp4",
|
|
duration_seconds=900,
|
|
content_type=ContentType.tutorial,
|
|
processing_status=ProcessingStatus.extracted,
|
|
)
|
|
session.add_all([video1, video2])
|
|
await session.flush()
|
|
|
|
# Technique pages
|
|
tp1 = TechniquePage(
|
|
creator_id=creator1.id,
|
|
title="Reese Bass Design",
|
|
slug="reese-bass-design",
|
|
topic_category="Sound design",
|
|
topic_tags=["bass", "textures"],
|
|
summary="How to create a classic reese bass",
|
|
)
|
|
tp2 = TechniquePage(
|
|
creator_id=creator2.id,
|
|
title="Granular Pad Textures",
|
|
slug="granular-pad-textures",
|
|
topic_category="Synthesis",
|
|
topic_tags=["granular", "pads"],
|
|
summary="Creating pad textures with granular synthesis",
|
|
)
|
|
tp3 = TechniquePage(
|
|
creator_id=creator1.id,
|
|
title="FM Bass Layering",
|
|
slug="fm-bass-layering",
|
|
topic_category="Synthesis",
|
|
topic_tags=["fm", "bass"],
|
|
summary="FM synthesis techniques for bass layering",
|
|
)
|
|
session.add_all([tp1, tp2, tp3])
|
|
await session.flush()
|
|
|
|
# Key moments
|
|
km1 = KeyMoment(
|
|
source_video_id=video1.id,
|
|
technique_page_id=tp1.id,
|
|
title="Setting up the Reese oscillator",
|
|
summary="Initial oscillator setup for reese bass",
|
|
start_time=10.0,
|
|
end_time=60.0,
|
|
content_type=KeyMomentContentType.technique,
|
|
)
|
|
km2 = KeyMoment(
|
|
source_video_id=video1.id,
|
|
technique_page_id=tp1.id,
|
|
title="Adding distortion to the Reese",
|
|
summary="Distortion processing chain for reese bass",
|
|
start_time=60.0,
|
|
end_time=120.0,
|
|
content_type=KeyMomentContentType.technique,
|
|
)
|
|
km3 = KeyMoment(
|
|
source_video_id=video2.id,
|
|
technique_page_id=tp2.id,
|
|
title="Granular engine settings",
|
|
summary="Dialing in granular engine parameters",
|
|
start_time=20.0,
|
|
end_time=80.0,
|
|
content_type=KeyMomentContentType.settings,
|
|
)
|
|
km4 = KeyMoment(
|
|
source_video_id=video1.id,
|
|
technique_page_id=tp3.id,
|
|
title="FM ratio selection",
|
|
summary="Choosing FM ratios for bass tones",
|
|
start_time=5.0,
|
|
end_time=45.0,
|
|
content_type=KeyMomentContentType.technique,
|
|
)
|
|
km5 = KeyMoment(
|
|
source_video_id=video2.id,
|
|
title="Outro and credits",
|
|
summary="End of the video",
|
|
start_time=800.0,
|
|
end_time=900.0,
|
|
content_type=KeyMomentContentType.workflow,
|
|
)
|
|
session.add_all([km1, km2, km3, km4, km5])
|
|
await session.commit()
|
|
|
|
return {
|
|
"creator1_id": str(creator1.id),
|
|
"creator1_name": creator1.name,
|
|
"creator1_slug": creator1.slug,
|
|
"creator2_id": str(creator2.id),
|
|
"creator2_name": creator2.name,
|
|
"tp1_slug": tp1.slug,
|
|
"tp1_title": tp1.title,
|
|
"tp2_slug": tp2.slug,
|
|
"tp3_slug": tp3.slug,
|
|
}
|
|
|
|
|
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_happy_path_with_mocked_service(client, db_engine):
|
|
"""Search endpoint returns mocked results with correct response shape."""
|
|
seed = await _seed_search_data(db_engine)
|
|
|
|
# Mock the SearchService.search method to return canned results
|
|
mock_result = {
|
|
"items": [
|
|
{
|
|
"type": "technique_page",
|
|
"title": "Reese Bass Design",
|
|
"slug": "reese-bass-design",
|
|
"summary": "How to create a classic reese bass",
|
|
"topic_category": "Sound design",
|
|
"topic_tags": ["bass", "textures"],
|
|
"creator_name": "Mr. Bill",
|
|
"creator_slug": "mr-bill",
|
|
"score": 0.95,
|
|
}
|
|
],
|
|
"total": 1,
|
|
"query": "reese bass",
|
|
"fallback_used": False,
|
|
}
|
|
|
|
with patch("routers.search.SearchService") as MockSvc:
|
|
instance = MockSvc.return_value
|
|
instance.search = AsyncMock(return_value=mock_result)
|
|
|
|
resp = await client.get(SEARCH_URL, params={"q": "reese bass"})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["query"] == "reese bass"
|
|
assert data["total"] == 1
|
|
assert data["fallback_used"] is False
|
|
assert len(data["items"]) == 1
|
|
|
|
item = data["items"][0]
|
|
assert item["title"] == "Reese Bass Design"
|
|
assert item["slug"] == "reese-bass-design"
|
|
assert "score" in item
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_empty_query_returns_empty(client, db_engine):
|
|
"""Empty search query returns empty results without hitting SearchService."""
|
|
await _seed_search_data(db_engine)
|
|
|
|
# With empty query, the search service returns empty results directly
|
|
mock_result = {
|
|
"items": [],
|
|
"total": 0,
|
|
"query": "",
|
|
"fallback_used": False,
|
|
}
|
|
|
|
with patch("routers.search.SearchService") as MockSvc:
|
|
instance = MockSvc.return_value
|
|
instance.search = AsyncMock(return_value=mock_result)
|
|
|
|
resp = await client.get(SEARCH_URL, params={"q": ""})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|
|
assert data["query"] == ""
|
|
assert data["fallback_used"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_keyword_fallback(client, db_engine):
|
|
"""When embedding fails, search uses keyword fallback and sets fallback_used=true."""
|
|
seed = await _seed_search_data(db_engine)
|
|
|
|
mock_result = {
|
|
"items": [
|
|
{
|
|
"type": "technique_page",
|
|
"title": "Reese Bass Design",
|
|
"slug": "reese-bass-design",
|
|
"summary": "How to create a classic reese bass",
|
|
"topic_category": "Sound design",
|
|
"topic_tags": ["bass", "textures"],
|
|
"creator_name": "",
|
|
"creator_slug": "",
|
|
"score": 0.0,
|
|
}
|
|
],
|
|
"total": 1,
|
|
"query": "reese",
|
|
"fallback_used": True,
|
|
}
|
|
|
|
with patch("routers.search.SearchService") as MockSvc:
|
|
instance = MockSvc.return_value
|
|
instance.search = AsyncMock(return_value=mock_result)
|
|
|
|
resp = await client.get(SEARCH_URL, params={"q": "reese"})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["fallback_used"] is True
|
|
assert data["total"] >= 1
|
|
assert data["items"][0]["title"] == "Reese Bass Design"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_scope_filter(client, db_engine):
|
|
"""Search with scope=topics returns only technique_page type results."""
|
|
await _seed_search_data(db_engine)
|
|
|
|
mock_result = {
|
|
"items": [
|
|
{
|
|
"type": "technique_page",
|
|
"title": "FM Bass Layering",
|
|
"slug": "fm-bass-layering",
|
|
"summary": "FM synthesis techniques for bass layering",
|
|
"topic_category": "Synthesis",
|
|
"topic_tags": ["fm", "bass"],
|
|
"creator_name": "Mr. Bill",
|
|
"creator_slug": "mr-bill",
|
|
"score": 0.88,
|
|
}
|
|
],
|
|
"total": 1,
|
|
"query": "bass",
|
|
"fallback_used": False,
|
|
}
|
|
|
|
with patch("routers.search.SearchService") as MockSvc:
|
|
instance = MockSvc.return_value
|
|
instance.search = AsyncMock(return_value=mock_result)
|
|
|
|
resp = await client.get(SEARCH_URL, params={"q": "bass", "scope": "topics"})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# All items should be technique_page type when scope=topics
|
|
for item in data["items"]:
|
|
assert item["type"] == "technique_page"
|
|
|
|
# Verify the service was called with scope=topics
|
|
call_kwargs = instance.search.call_args
|
|
assert call_kwargs.kwargs.get("scope") == "topics" or call_kwargs[1].get("scope") == "topics"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_no_matching_results(client, db_engine):
|
|
"""Search with no matching results returns empty items list."""
|
|
await _seed_search_data(db_engine)
|
|
|
|
mock_result = {
|
|
"items": [],
|
|
"total": 0,
|
|
"query": "zzzznonexistent",
|
|
"fallback_used": True,
|
|
}
|
|
|
|
with patch("routers.search.SearchService") as MockSvc:
|
|
instance = MockSvc.return_value
|
|
instance.search = AsyncMock(return_value=mock_result)
|
|
|
|
resp = await client.get(SEARCH_URL, params={"q": "zzzznonexistent"})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|