use crate::middleware::auth::UserContext; use crate::state::SharedState; use axum::{ Json, extract::{Extension, Query, State}, http::StatusCode, response::IntoResponse, }; use serde::Deserialize; use serde_json::json; #[derive(Deserialize)] pub struct SearchQuery { q: String, #[serde(default = "default_limit")] limit: i64, #[serde(default = "default_offset")] offset: i64, source: Option, } fn default_limit() -> i64 { 20 } fn default_offset() -> i64 { 0 } /// GET /api/search?q=... /// Search for decks, cards, and notes using full-text search /// /// TODO: filter by user pub async fn search( State(state): State, ctx: Option>, Query(query): Query, ) -> impl IntoResponse { let user_did = ctx.map(|Extension(u)| u.did); match state .search_repo .search( &query.q, query.limit, query.offset, user_did.as_deref(), query.source.as_deref(), ) .await { Ok(results) => Json(results).into_response(), Err(e) => { tracing::error!("Search failed: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Search failed"})), ) .into_response() } } } /// GET /api/discovery /// Get discovery info like top tags pub async fn discovery(State(state): State) -> impl IntoResponse { match state.search_repo.get_top_tags(10).await { Ok(tags) => Json(json!({ "top_tags": tags })).into_response(), Err(e) => { tracing::error!("Discovery failed: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Discovery failed"})), ) .into_response() } } } #[cfg(test)] mod tests { use super::*; use crate::repository::card::mock::MockCardRepository; use crate::repository::deck::mock::MockDeckRepository; use crate::repository::note::mock::MockNoteRepository; use crate::repository::oauth::mock::MockOAuthRepository; use crate::repository::review::mock::MockReviewRepository; use crate::repository::search::mock::MockSearchRepository; use crate::repository::search::{SearchRepository, SearchResult}; use crate::repository::social::mock::MockSocialRepository; use crate::state::AppState; use std::sync::Arc; fn create_test_state_with_search(search_repo: Arc) -> SharedState { let pool = crate::db::create_mock_pool(); let card_repo = Arc::new(MockCardRepository::new()) as Arc; let note_repo = Arc::new(MockNoteRepository::new()) as Arc; let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let social_repo = Arc::new(MockSocialRepository::new()) as Arc; let deck_repo = Arc::new(MockDeckRepository::new()) as Arc; let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); let search_repo_trait = search_repo.clone() as Arc; let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) as Arc; let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new()) as Arc; Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, prefs_repo, review_repo, social_repo, deck_repo, search_repo: search_repo_trait, sync_repo, config, auth_cache, dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), identity_resolver: crate::oauth::resolver::IdentityResolver::new(), }) } #[tokio::test] async fn test_search_handler_passes_viewer_did() { let search_repo = Arc::new(MockSearchRepository::new()); search_repo .add_result(SearchResult { item_type: "deck".to_string(), item_id: "private-deck".to_string(), creator_did: "did:alice".to_string(), data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }), rank: 1.0, source: "local".to_string(), }) .await; let state = create_test_state_with_search(search_repo.clone()); let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string(), access_token: "test_token".to_string(), pds_url: "https://bsky.social".to_string(), has_dpop: false, }); let response = search( State(state.clone()), Some(auth_ctx), Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), ) .await .into_response(); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let results: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(results.len(), 1, "Alice should see her private deck"); assert_eq!(results[0].item_id, "private-deck"); let response_anon = search( State(state.clone()), None, Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), ) .await .into_response(); let body_anon = axum::body::to_bytes(response_anon.into_body(), usize::MAX) .await .unwrap(); let results_anon: Vec = serde_json::from_slice(&body_anon).unwrap(); assert_eq!(results_anon.len(), 0, "Anonymous user should not see private deck"); } }