learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 177 lines 6.5 kB view raw
1use crate::middleware::auth::UserContext; 2use crate::state::SharedState; 3use axum::{ 4 Json, 5 extract::{Extension, Query, State}, 6 http::StatusCode, 7 response::IntoResponse, 8}; 9use serde::Deserialize; 10use serde_json::json; 11 12#[derive(Deserialize)] 13pub struct SearchQuery { 14 q: String, 15 #[serde(default = "default_limit")] 16 limit: i64, 17 #[serde(default = "default_offset")] 18 offset: i64, 19 source: Option<String>, 20} 21 22fn default_limit() -> i64 { 23 20 24} 25 26fn default_offset() -> i64 { 27 0 28} 29 30/// GET /api/search?q=... 31/// Search for decks, cards, and notes using full-text search 32/// 33/// TODO: filter by user 34pub async fn search( 35 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<SearchQuery>, 36) -> impl IntoResponse { 37 let user_did = ctx.map(|Extension(u)| u.did); 38 39 match state 40 .search_repo 41 .search( 42 &query.q, 43 query.limit, 44 query.offset, 45 user_did.as_deref(), 46 query.source.as_deref(), 47 ) 48 .await 49 { 50 Ok(results) => Json(results).into_response(), 51 Err(e) => { 52 tracing::error!("Search failed: {:?}", e); 53 ( 54 StatusCode::INTERNAL_SERVER_ERROR, 55 Json(json!({"error": "Search failed"})), 56 ) 57 .into_response() 58 } 59 } 60} 61 62/// GET /api/discovery 63/// Get discovery info like top tags 64pub async fn discovery(State(state): State<SharedState>) -> impl IntoResponse { 65 match state.search_repo.get_top_tags(10).await { 66 Ok(tags) => Json(json!({ "top_tags": tags })).into_response(), 67 Err(e) => { 68 tracing::error!("Discovery failed: {:?}", e); 69 ( 70 StatusCode::INTERNAL_SERVER_ERROR, 71 Json(json!({"error": "Discovery failed"})), 72 ) 73 .into_response() 74 } 75 } 76} 77 78#[cfg(test)] 79mod tests { 80 use super::*; 81 use crate::repository::card::mock::MockCardRepository; 82 use crate::repository::deck::mock::MockDeckRepository; 83 use crate::repository::note::mock::MockNoteRepository; 84 use crate::repository::oauth::mock::MockOAuthRepository; 85 use crate::repository::review::mock::MockReviewRepository; 86 use crate::repository::search::mock::MockSearchRepository; 87 use crate::repository::search::{SearchRepository, SearchResult}; 88 use crate::repository::social::mock::MockSocialRepository; 89 use crate::state::AppState; 90 use std::sync::Arc; 91 92 fn create_test_state_with_search(search_repo: Arc<MockSearchRepository>) -> SharedState { 93 let pool = crate::db::create_mock_pool(); 94 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 95 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 96 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 97 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 98 let social_repo = Arc::new(MockSocialRepository::new()) as Arc<dyn crate::repository::social::SocialRepository>; 99 let deck_repo = Arc::new(MockDeckRepository::new()) as Arc<dyn crate::repository::deck::DeckRepository>; 100 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 101 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 102 let search_repo_trait = search_repo.clone() as Arc<dyn SearchRepository>; 103 let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 104 as Arc<dyn crate::repository::preferences::PreferencesRepository>; 105 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new()) 106 as Arc<dyn crate::repository::sync::SyncRepository>; 107 108 Arc::new(AppState { 109 pool, 110 card_repo, 111 note_repo, 112 oauth_repo, 113 prefs_repo, 114 review_repo, 115 social_repo, 116 deck_repo, 117 search_repo: search_repo_trait, 118 sync_repo, 119 config, 120 auth_cache, 121 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 122 identity_resolver: crate::oauth::resolver::IdentityResolver::new(), 123 }) 124 } 125 126 #[tokio::test] 127 async fn test_search_handler_passes_viewer_did() { 128 let search_repo = Arc::new(MockSearchRepository::new()); 129 search_repo 130 .add_result(SearchResult { 131 item_type: "deck".to_string(), 132 item_id: "private-deck".to_string(), 133 creator_did: "did:alice".to_string(), 134 data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }), 135 rank: 1.0, 136 source: "local".to_string(), 137 }) 138 .await; 139 140 let state = create_test_state_with_search(search_repo.clone()); 141 let auth_ctx = Extension(UserContext { 142 did: "did:alice".to_string(), 143 handle: "alice.test".to_string(), 144 access_token: "test_token".to_string(), 145 pds_url: "https://bsky.social".to_string(), 146 has_dpop: false, 147 }); 148 let response = search( 149 State(state.clone()), 150 Some(auth_ctx), 151 Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), 152 ) 153 .await 154 .into_response(); 155 156 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 157 let results: Vec<SearchResult> = serde_json::from_slice(&body).unwrap(); 158 159 assert_eq!(results.len(), 1, "Alice should see her private deck"); 160 assert_eq!(results[0].item_id, "private-deck"); 161 162 let response_anon = search( 163 State(state.clone()), 164 None, 165 Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), 166 ) 167 .await 168 .into_response(); 169 170 let body_anon = axum::body::to_bytes(response_anon.into_body(), usize::MAX) 171 .await 172 .unwrap(); 173 let results_anon: Vec<SearchResult> = serde_json::from_slice(&body_anon).unwrap(); 174 175 assert_eq!(results_anon.len(), 0, "Anonymous user should not see private deck"); 176 } 177}