learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use crate::middleware::auth::UserContext;
2use crate::state::SharedState;
3
4use axum::{
5 Json,
6 extract::{Extension, Path, State},
7 http::StatusCode,
8 response::IntoResponse,
9};
10use serde::Deserialize;
11use serde_json::json;
12
13#[derive(Deserialize)]
14pub struct AddCommentRequest {
15 pub content: String,
16 pub parent_id: Option<String>,
17}
18
19pub async fn follow(
20 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>,
21) -> impl IntoResponse {
22 let user = match ctx {
23 Some(Extension(user)) => user,
24 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
25 };
26
27 let result = state.social_repo.follow(&user.did, &subject_did).await;
28
29 match result {
30 Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(),
31 Err(malfestio_core::Error::Database(msg)) => {
32 tracing::error!("Database error: {}", msg);
33 (
34 StatusCode::INTERNAL_SERVER_ERROR,
35 Json(json!({"error": "Failed to follow"})),
36 )
37 .into_response()
38 }
39 Err(e) => (
40 StatusCode::INTERNAL_SERVER_ERROR,
41 Json(json!({"error": format!("{:?}", e)})),
42 )
43 .into_response(),
44 }
45}
46
47pub async fn unfollow(
48 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>,
49) -> impl IntoResponse {
50 let user = match ctx {
51 Some(Extension(user)) => user,
52 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
53 };
54
55 let result = state.social_repo.unfollow(&user.did, &subject_did).await;
56
57 match result {
58 Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(),
59 Err(malfestio_core::Error::Database(msg)) => {
60 tracing::error!("Database error: {}", msg);
61 (
62 StatusCode::INTERNAL_SERVER_ERROR,
63 Json(json!({"error": "Failed to unfollow"})),
64 )
65 .into_response()
66 }
67 Err(e) => (
68 StatusCode::INTERNAL_SERVER_ERROR,
69 Json(json!({"error": format!("{:?}", e)})),
70 )
71 .into_response(),
72 }
73}
74
75pub async fn get_followers(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse {
76 let result = state.social_repo.get_followers(&did).await;
77
78 match result {
79 Ok(followers) => Json(followers).into_response(),
80 Err(malfestio_core::Error::Database(msg)) => {
81 tracing::error!("Database error: {}", msg);
82 (
83 StatusCode::INTERNAL_SERVER_ERROR,
84 Json(json!({"error": "Failed to get followers"})),
85 )
86 .into_response()
87 }
88 Err(e) => (
89 StatusCode::INTERNAL_SERVER_ERROR,
90 Json(json!({"error": format!("{:?}", e)})),
91 )
92 .into_response(),
93 }
94}
95
96pub async fn get_following(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse {
97 let result = state.social_repo.get_following(&did).await;
98
99 match result {
100 Ok(following) => Json(following).into_response(),
101 Err(malfestio_core::Error::Database(msg)) => {
102 tracing::error!("Database error: {}", msg);
103 (
104 StatusCode::INTERNAL_SERVER_ERROR,
105 Json(json!({"error": "Failed to get following"})),
106 )
107 .into_response()
108 }
109 Err(e) => (
110 StatusCode::INTERNAL_SERVER_ERROR,
111 Json(json!({"error": format!("{:?}", e)})),
112 )
113 .into_response(),
114 }
115}
116
117pub async fn add_comment(
118 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(deck_id): Path<String>,
119 Json(payload): Json<AddCommentRequest>,
120) -> impl IntoResponse {
121 let user = match ctx {
122 Some(Extension(user)) => user,
123 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
124 };
125
126 let result = state
127 .social_repo
128 .add_comment(&deck_id, &user.did, &payload.content, payload.parent_id.as_deref())
129 .await;
130
131 match result {
132 Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(),
133 Err(malfestio_core::Error::Database(msg)) => {
134 tracing::error!("Database error: {}", msg);
135 (
136 StatusCode::INTERNAL_SERVER_ERROR,
137 Json(json!({"error": "Failed to add comment"})),
138 )
139 .into_response()
140 }
141 Err(e) => (
142 StatusCode::INTERNAL_SERVER_ERROR,
143 Json(json!({"error": format!("{:?}", e)})),
144 )
145 .into_response(),
146 }
147}
148
149pub async fn get_comments(State(state): State<SharedState>, Path(deck_id): Path<String>) -> impl IntoResponse {
150 let result = state.social_repo.get_comments(&deck_id).await;
151
152 match result {
153 Ok(comments) => Json(comments).into_response(),
154 Err(malfestio_core::Error::Database(msg)) => {
155 tracing::error!("Database error: {}", msg);
156 (
157 StatusCode::INTERNAL_SERVER_ERROR,
158 Json(json!({"error": "Failed to get comments"})),
159 )
160 .into_response()
161 }
162 Err(e) => (
163 StatusCode::INTERNAL_SERVER_ERROR,
164 Json(json!({"error": format!("{:?}", e)})),
165 )
166 .into_response(),
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::middleware::auth::UserContext;
174 use crate::repository::card::mock::MockCardRepository;
175 use crate::repository::note::mock::MockNoteRepository;
176 use crate::repository::oauth::mock::MockOAuthRepository;
177 use crate::repository::review::mock::MockReviewRepository;
178 use crate::repository::social::{SocialRepository, mock::MockSocialRepository};
179 use crate::state::AppState;
180 use axum::extract::Json;
181 use std::sync::Arc;
182
183 fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState {
184 let pool = crate::db::create_mock_pool();
185 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
186 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
187 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
188 let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new())
189 as Arc<dyn crate::repository::preferences::PreferencesRepository>;
190 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>;
191
192 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new())
193 as Arc<dyn crate::repository::deck::DeckRepository>;
194 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() };
195 let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new())
196 as Arc<dyn crate::repository::search::SearchRepository>;
197 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
198 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new())
199 as Arc<dyn crate::repository::sync::SyncRepository>;
200
201 Arc::new(AppState {
202 pool,
203 card_repo,
204 note_repo,
205 oauth_repo,
206 prefs_repo: preferences_repo,
207 review_repo,
208 social_repo,
209 deck_repo,
210 search_repo,
211 sync_repo,
212 config,
213 auth_cache,
214 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
215 identity_resolver: crate::oauth::resolver::IdentityResolver::new(),
216 })
217 }
218
219 #[tokio::test]
220 async fn test_follow_success() {
221 let social_repo = Arc::new(MockSocialRepository::new());
222 let state = create_test_state_with_social(social_repo.clone());
223 let user = UserContext {
224 did: "did:plc:follower".to_string(),
225 handle: "follower".to_string(),
226 access_token: "test_token".to_string(),
227 pds_url: "https://bsky.social".to_string(),
228 has_dpop: false,
229 };
230
231 let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
232 .await
233 .into_response();
234
235 assert_eq!(response.status(), StatusCode::OK);
236
237 let followers = social_repo.get_followers("did:plc:subject").await.unwrap();
238 assert!(followers.contains(&"did:plc:follower".to_string()));
239 }
240
241 #[tokio::test]
242 async fn test_unfollow_success() {
243 let social_repo = Arc::new(MockSocialRepository::new());
244 social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap();
245
246 let state = create_test_state_with_social(social_repo.clone());
247 let user = UserContext {
248 did: "did:plc:follower".to_string(),
249 handle: "follower".to_string(),
250 access_token: "test_token".to_string(),
251 pds_url: "https://bsky.social".to_string(),
252 has_dpop: false,
253 };
254
255 let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
256 .await
257 .into_response();
258
259 assert_eq!(response.status(), StatusCode::OK);
260
261 let followers = social_repo.get_followers("did:plc:subject").await.unwrap();
262 assert!(followers.is_empty());
263 }
264
265 #[tokio::test]
266 async fn test_get_followers() {
267 let social_repo = Arc::new(MockSocialRepository::new());
268 social_repo.follow("did:plc:1", "did:plc:subject").await.unwrap();
269 social_repo.follow("did:plc:2", "did:plc:subject").await.unwrap();
270
271 let state = create_test_state_with_social(social_repo);
272
273 let response = get_followers(State(state), Path("did:plc:subject".to_string()))
274 .await
275 .into_response();
276
277 assert_eq!(response.status(), StatusCode::OK);
278
279 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
280 let followers: Vec<String> = serde_json::from_slice(&body_bytes).unwrap();
281
282 assert_eq!(followers.len(), 2);
283 assert!(followers.contains(&"did:plc:1".to_string()));
284 assert!(followers.contains(&"did:plc:2".to_string()));
285 }
286
287 #[tokio::test]
288 async fn test_add_comment_success() {
289 let social_repo = Arc::new(MockSocialRepository::new());
290 let state = create_test_state_with_social(social_repo.clone());
291 let user = UserContext {
292 did: "did:plc:author".to_string(),
293 handle: "author".to_string(),
294 access_token: "test_token".to_string(),
295 pds_url: "https://bsky.social".to_string(),
296 has_dpop: false,
297 };
298
299 let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None };
300
301 let response = add_comment(
302 State(state),
303 Some(Extension(user)),
304 Path("deck-1".to_string()),
305 Json(payload),
306 )
307 .await
308 .into_response();
309
310 assert_eq!(response.status(), StatusCode::CREATED);
311
312 let comments = social_repo.get_comments("deck-1").await.unwrap();
313 assert_eq!(comments.len(), 1);
314 assert_eq!(comments[0].content, "Great deck!");
315 }
316}