use crate::{ errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response, state::AppState, }; use api_types::requests::PaginationQuery; use application::use_cases::feed::get_user_feed; use axum::{ extract::{Path, Query, State}, Json, }; use domain::{events::DomainEvent, models::feed::PageParams}; pub async fn remote_actor_posts_handler( State(s): State, Path(handle): Path, Query(q): Query, OptionalAuthUser(viewer): OptionalAuthUser, ) -> Result, ApiError> { let actor = s.federation.lookup_actor(&handle).await?; let ap_url = url::Url::parse(&actor.url).map_err(|e| ApiError::BadRequest(e.to_string()))?; // Get or create interned local UserId for this remote actor let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? { Some(id) => id, None => s.ap_repo.intern_remote_actor(&ap_url).await?, }; // Return cached posts from DB let page = PageParams { page: q.page(), per_page: q.per_page(), }; let result = get_user_feed(&*s.feed, &author_id, page, viewer.as_ref()).await?; // Trigger background outbox fetch (fire and forget) if let Some(outbox_url) = &actor.outbox_url { let _ = s .events .publish(&DomainEvent::FetchRemoteActorPosts { actor_ap_url: actor.url.clone(), outbox_url: outbox_url.clone(), }) .await; } Ok(Json(serde_json::json!({ "total": result.total, "page": result.page, "per_page": result.per_page, "items": result.items.iter().map(to_thought_response).collect::>(), }))) } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use axum::{body::Body, http::Request, routing::get, Router}; use domain::{ errors::DomainError, ports::{AuthService, GeneratedToken, PasswordHasher}, testing::TestStore, value_objects::{PasswordHash, UserId}, }; use std::sync::Arc; use tower::ServiceExt; struct NoOpAuth; impl AuthService for NoOpAuth { fn generate_token(&self, _uid: &UserId) -> Result { Err(DomainError::Internal("noop".into())) } fn validate_token(&self, _token: &str) -> Result { Err(DomainError::Unauthorized) } } struct NoOpHasher; #[async_trait] impl PasswordHasher for NoOpHasher { async fn hash(&self, _plain: &str) -> Result { Err(DomainError::Internal("noop".into())) } async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { Ok(false) } } fn make_state() -> crate::state::AppState { let store = Arc::new(TestStore::default()); crate::state::AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: Arc::new(NoOpAuth), hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), ap_repo: store.clone(), } } fn app() -> Router { Router::new() .route( "/federation/actors/{handle}/posts", get(remote_actor_posts_handler), ) .with_state(make_state()) } #[tokio::test] async fn unknown_actor_returns_404() { let resp = app() .oneshot( Request::builder() .uri("/federation/actors/%40alice%40example.com/posts") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 404); } }