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 284 lines 10 kB view raw
1use crate::middleware::auth::UserContext; 2use crate::repository::review::ReviewRepoError; 3use crate::state::SharedState; 4 5use axum::{ 6 Json, 7 extract::{Extension, Query, State}, 8 http::StatusCode, 9 response::IntoResponse, 10}; 11use malfestio_core::srs::Grade; 12use serde::{Deserialize, Serialize}; 13use serde_json::json; 14 15#[derive(Deserialize)] 16pub struct DueCardsQuery { 17 deck_id: Option<String>, 18 #[serde(default = "default_limit")] 19 limit: i64, 20} 21 22fn default_limit() -> i64 { 23 20 24} 25 26#[derive(Deserialize)] 27pub struct SubmitReviewRequest { 28 card_id: String, 29 grade: u8, 30} 31 32#[derive(Serialize)] 33pub struct SubmitReviewResponse { 34 ease_factor: f32, 35 interval_days: i32, 36 repetitions: i32, 37 due_at: String, 38} 39 40/// GET /api/review/due - Get cards due for review 41pub async fn get_due_cards( 42 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<DueCardsQuery>, 43) -> impl IntoResponse { 44 let user = match ctx { 45 Some(Extension(user)) => user, 46 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 47 }; 48 49 let result = state 50 .review_repo 51 .get_due_cards(&user.did, query.deck_id.as_deref(), query.limit) 52 .await; 53 54 match result { 55 Ok(cards) => Json(cards).into_response(), 56 Err(ReviewRepoError::InvalidArgument(msg)) => { 57 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 58 } 59 Err(e) => { 60 tracing::error!("Failed to get due cards: {:?}", e); 61 ( 62 StatusCode::INTERNAL_SERVER_ERROR, 63 Json(json!({"error": "Failed to get due cards"})), 64 ) 65 .into_response() 66 } 67 } 68} 69 70/// POST /api/review/submit - Submit a review grade 71pub async fn submit_review( 72 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<SubmitReviewRequest>, 73) -> impl IntoResponse { 74 let user = match ctx { 75 Some(Extension(user)) => user, 76 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 77 }; 78 79 let grade = match Grade::new(payload.grade) { 80 Some(g) => g, 81 None => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Grade must be 0-5"}))).into_response(), 82 }; 83 84 let result = state 85 .review_repo 86 .submit_review(&user.did, &payload.card_id, grade) 87 .await; 88 89 match result { 90 Ok(new_state) => Json(SubmitReviewResponse { 91 ease_factor: new_state.ease_factor, 92 interval_days: new_state.interval_days, 93 repetitions: new_state.repetitions, 94 due_at: new_state.due_at.to_rfc3339(), 95 }) 96 .into_response(), 97 Err(ReviewRepoError::InvalidArgument(msg)) => { 98 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 99 } 100 Err(e) => { 101 tracing::error!("Failed to submit review: {:?}", e); 102 ( 103 StatusCode::INTERNAL_SERVER_ERROR, 104 Json(json!({"error": "Failed to submit review"})), 105 ) 106 .into_response() 107 } 108 } 109} 110 111/// GET /api/review/stats - Get user study statistics 112pub async fn get_stats(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 113 let user = match ctx { 114 Some(Extension(user)) => user, 115 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 116 }; 117 118 let result = state.review_repo.get_stats(&user.did).await; 119 120 match result { 121 Ok(stats) => Json(stats).into_response(), 122 Err(e) => { 123 tracing::error!("Failed to get stats: {:?}", e); 124 ( 125 StatusCode::INTERNAL_SERVER_ERROR, 126 Json(json!({"error": "Failed to get stats"})), 127 ) 128 .into_response() 129 } 130 } 131} 132 133#[cfg(test)] 134mod tests { 135 use super::*; 136 use crate::repository::card::mock::MockCardRepository; 137 use crate::repository::note::mock::MockNoteRepository; 138 use crate::repository::oauth::mock::MockOAuthRepository; 139 use crate::repository::review::mock::MockReviewRepository; 140 use crate::repository::review::{ReviewCard, ReviewRepository}; 141 use crate::state::AppState; 142 use chrono::Utc; 143 use std::sync::Arc; 144 145 fn create_test_state_with_review(review_repo: Arc<dyn ReviewRepository>) -> SharedState { 146 let pool = crate::db::create_mock_pool(); 147 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 148 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 149 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 150 let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 151 as Arc<dyn crate::repository::preferences::PreferencesRepository>; 152 let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) 153 as Arc<dyn crate::repository::social::SocialRepository>; 154 155 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 156 as Arc<dyn crate::repository::deck::DeckRepository>; 157 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 158 159 let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 160 as Arc<dyn crate::repository::search::SearchRepository>; 161 162 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new()) 163 as Arc<dyn crate::repository::sync::SyncRepository>; 164 165 let repos = crate::state::Repositories { 166 card: card_repo, 167 note: note_repo, 168 oauth: oauth_repo, 169 prefs: preferences_repo, 170 review: review_repo, 171 social: social_repo, 172 deck: deck_repo, 173 search: search_repo, 174 sync: sync_repo, 175 }; 176 177 AppState::new(pool, repos, config) 178 } 179 180 #[tokio::test] 181 async fn test_get_due_cards_unauthorized() { 182 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 183 let state = create_test_state_with_review(review_repo); 184 185 let response = get_due_cards(State(state), None, Query(DueCardsQuery { deck_id: None, limit: 20 })) 186 .await 187 .into_response(); 188 189 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 190 } 191 192 #[tokio::test] 193 async fn test_get_due_cards_success() { 194 let cards = vec![ReviewCard { 195 review_id: "review-1".to_string(), 196 card_id: "card-1".to_string(), 197 deck_id: "deck-1".to_string(), 198 deck_title: "Test Deck".to_string(), 199 front: "What is 2+2?".to_string(), 200 back: "4".to_string(), 201 media_url: None, 202 hints: vec![], 203 due_at: Utc::now(), 204 }]; 205 let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>; 206 let state = create_test_state_with_review(review_repo); 207 208 let user = UserContext { 209 did: "did:plc:test".to_string(), 210 handle: "test.handle".to_string(), 211 access_token: "test_token".to_string(), 212 pds_url: "https://bsky.social".to_string(), 213 has_dpop: false, 214 }; 215 let response = get_due_cards( 216 State(state), 217 Some(Extension(user)), 218 Query(DueCardsQuery { deck_id: None, limit: 20 }), 219 ) 220 .await 221 .into_response(); 222 223 assert_eq!(response.status(), StatusCode::OK); 224 } 225 226 #[tokio::test] 227 async fn test_submit_review_success() { 228 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 229 let state = create_test_state_with_review(review_repo); 230 231 let user = UserContext { 232 did: "did:plc:test".to_string(), 233 handle: "test.handle".to_string(), 234 access_token: "test_token".to_string(), 235 pds_url: "https://bsky.social".to_string(), 236 has_dpop: false, 237 }; 238 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 }; 239 240 let response = submit_review(State(state), Some(Extension(user)), Json(payload)) 241 .await 242 .into_response(); 243 244 assert_eq!(response.status(), StatusCode::OK); 245 } 246 247 #[tokio::test] 248 async fn test_submit_review_invalid_grade() { 249 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 250 let state = create_test_state_with_review(review_repo); 251 252 let user = UserContext { 253 did: "did:plc:test".to_string(), 254 handle: "test.handle".to_string(), 255 access_token: "test_token".to_string(), 256 pds_url: "https://bsky.social".to_string(), 257 has_dpop: false, 258 }; 259 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 }; 260 261 let response = submit_review(State(state), Some(Extension(user)), Json(payload)) 262 .await 263 .into_response(); 264 265 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 266 } 267 268 #[tokio::test] 269 async fn test_get_stats_success() { 270 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 271 let state = create_test_state_with_review(review_repo); 272 273 let user = UserContext { 274 did: "did:plc:test".to_string(), 275 handle: "test.handle".to_string(), 276 access_token: "test_token".to_string(), 277 pds_url: "https://bsky.social".to_string(), 278 has_dpop: false, 279 }; 280 let response = get_stats(State(state), Some(Extension(user))).await.into_response(); 281 282 assert_eq!(response.status(), StatusCode::OK); 283 } 284}