learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use crate::middleware::auth::UserContext;
2use crate::repository::preferences::{PreferencesRepoError, UpdatePreferences};
3use crate::state::SharedState;
4
5use axum::{
6 Json,
7 extract::{Extension, State},
8 http::StatusCode,
9 response::IntoResponse,
10};
11use serde::Deserialize;
12use serde_json::json;
13
14#[derive(Deserialize)]
15pub struct UpdatePreferencesRequest {
16 pub persona: Option<String>,
17 pub complete_onboarding: Option<bool>,
18 pub tutorial_deck_completed: Option<bool>,
19 pub density_mode: Option<String>,
20}
21
22/// GET /api/preferences - Get current user preferences
23pub async fn get_preferences(
24 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>,
25) -> impl IntoResponse {
26 let user = match ctx {
27 Some(Extension(user)) => user,
28 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
29 };
30
31 let result = state.prefs_repo.get_or_create(&user.did).await;
32
33 match result {
34 Ok(prefs) => Json(prefs).into_response(),
35 Err(e) => {
36 tracing::error!("Failed to get preferences: {:?}", e);
37 (
38 StatusCode::INTERNAL_SERVER_ERROR,
39 Json(json!({"error": "Failed to get preferences"})),
40 )
41 .into_response()
42 }
43 }
44}
45
46/// PUT /api/preferences - Update user preferences
47pub async fn update_preferences(
48 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>,
49 Json(payload): Json<UpdatePreferencesRequest>,
50) -> impl IntoResponse {
51 let user = match ctx {
52 Some(Extension(user)) => user,
53 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
54 };
55
56 let persona = if let Some(ref p) = payload.persona {
57 match p.parse() {
58 Ok(persona) => Some(persona),
59 Err(_) => {
60 return (
61 StatusCode::BAD_REQUEST,
62 Json(json!({"error": "Invalid persona. Must be 'learner', 'creator', or 'curator'"})),
63 )
64 .into_response();
65 }
66 }
67 } else {
68 None
69 };
70
71 let updates = UpdatePreferences {
72 persona,
73 complete_onboarding: payload.complete_onboarding,
74 tutorial_deck_completed: payload.tutorial_deck_completed,
75 density_mode: payload.density_mode,
76 };
77
78 let result = state.prefs_repo.update(&user.did, updates).await;
79
80 match result {
81 Ok(prefs) => Json(prefs).into_response(),
82 Err(PreferencesRepoError::NotFound(msg)) => {
83 (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response()
84 }
85 Err(e) => {
86 tracing::error!("Failed to update preferences: {:?}", e);
87 (
88 StatusCode::INTERNAL_SERVER_ERROR,
89 Json(json!({"error": "Failed to update preferences"})),
90 )
91 .into_response()
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::repository::preferences::PreferencesRepository;
100 use crate::repository::preferences::mock::MockPreferencesRepository;
101 use crate::state::{AppConfig, AppState, Repositories};
102 use std::sync::Arc;
103
104 fn create_test_state_with_prefs(prefs_repo: Arc<dyn PreferencesRepository>) -> SharedState {
105 let pool = crate::db::create_mock_pool();
106 let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new())
107 as Arc<dyn crate::repository::card::CardRepository>;
108 let note_repo = Arc::new(crate::repository::note::mock::MockNoteRepository::new())
109 as Arc<dyn crate::repository::note::NoteRepository>;
110 let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new())
111 as Arc<dyn crate::repository::oauth::OAuthRepository>;
112 let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new())
113 as Arc<dyn crate::repository::social::SocialRepository>;
114 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new())
115 as Arc<dyn crate::repository::deck::DeckRepository>;
116 let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new())
117 as Arc<dyn crate::repository::search::SearchRepository>;
118 let review_repo = Arc::new(crate::repository::review::mock::MockReviewRepository::new())
119 as Arc<dyn crate::repository::review::ReviewRepository>;
120 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new())
121 as Arc<dyn crate::repository::sync::SyncRepository>;
122
123 let config = AppConfig { pds_url: "https://bsky.social".to_string() };
124
125 let repos = Repositories {
126 card: card_repo,
127 note: note_repo,
128 oauth: oauth_repo,
129 review: review_repo,
130 social: social_repo,
131 deck: deck_repo,
132 search: search_repo,
133 prefs: prefs_repo,
134 sync: sync_repo,
135 };
136
137 AppState::new(pool, repos, config)
138 }
139
140 #[tokio::test]
141 async fn test_get_preferences_unauthorized() {
142 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
143 let state = create_test_state_with_prefs(prefs_repo);
144
145 let response = get_preferences(State(state), None).await.into_response();
146 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
147 }
148
149 #[tokio::test]
150 async fn test_get_preferences_success() {
151 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
152 let state = create_test_state_with_prefs(prefs_repo);
153
154 let user = UserContext {
155 did: "did:plc:test".to_string(),
156 handle: "test.handle".to_string(),
157 access_token: "test_token".to_string(),
158 pds_url: "https://bsky.social".to_string(),
159 has_dpop: false,
160 };
161 let response = get_preferences(State(state), Some(Extension(user)))
162 .await
163 .into_response();
164
165 assert_eq!(response.status(), StatusCode::OK);
166 }
167
168 #[tokio::test]
169 async fn test_update_preferences_set_persona() {
170 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
171 let state = create_test_state_with_prefs(prefs_repo);
172
173 let user = UserContext {
174 did: "did:plc:test".to_string(),
175 handle: "test.handle".to_string(),
176 access_token: "test_token".to_string(),
177 pds_url: "https://bsky.social".to_string(),
178 has_dpop: false,
179 };
180 let payload = UpdatePreferencesRequest {
181 persona: Some("creator".to_string()),
182 complete_onboarding: Some(true),
183 tutorial_deck_completed: None,
184 density_mode: None,
185 };
186
187 let response = update_preferences(State(state), Some(Extension(user)), Json(payload))
188 .await
189 .into_response();
190
191 assert_eq!(response.status(), StatusCode::OK);
192 }
193
194 #[tokio::test]
195 async fn test_update_preferences_invalid_persona() {
196 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
197 let state = create_test_state_with_prefs(prefs_repo);
198
199 let user = UserContext {
200 did: "did:plc:test".to_string(),
201 handle: "test.handle".to_string(),
202 access_token: "test_token".to_string(),
203 pds_url: "https://bsky.social".to_string(),
204 has_dpop: false,
205 };
206 let payload = UpdatePreferencesRequest {
207 persona: Some("invalid".to_string()),
208 complete_onboarding: None,
209 tutorial_deck_completed: None,
210 density_mode: None,
211 };
212
213 let response = update_preferences(State(state), Some(Extension(user)), Json(payload))
214 .await
215 .into_response();
216
217 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
218 }
219}