From abc5f2b936baf38e9b67cf28e93685fc4c5dbdf2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 21:05:30 +0200 Subject: [PATCH] refactor(api): notification state changes use PATCH with JSON body --- crates/api-types/src/requests.rs | 4 +- .../presentation/src/handlers/federation.rs | 7 +- .../src/handlers/notifications.rs | 125 +++++++++++++++++- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index 34e536d..160e702 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -83,6 +83,6 @@ pub struct SearchQuery { #[derive(serde::Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] -pub struct FollowRemoteRequest { - pub handle: String, +pub struct NotificationUpdateRequest { + pub read: bool, } diff --git a/crates/presentation/src/handlers/federation.rs b/crates/presentation/src/handlers/federation.rs index 0e1504d..f2881f5 100644 --- a/crates/presentation/src/handlers/federation.rs +++ b/crates/presentation/src/handlers/federation.rs @@ -5,7 +5,12 @@ use axum::{ }; use serde::Deserialize; -use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; +use api_types::responses::RemoteActorResponse; + +#[derive(serde::Deserialize)] +pub struct FollowRemoteRequest { + pub handle: String, +} use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; diff --git a/crates/presentation/src/handlers/notifications.rs b/crates/presentation/src/handlers/notifications.rs index 9222722..729c153 100644 --- a/crates/presentation/src/handlers/notifications.rs +++ b/crates/presentation/src/handlers/notifications.rs @@ -1,4 +1,5 @@ use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; +use api_types::requests::NotificationUpdateRequest; use application::use_cases::notifications::{ list_notifications as uc_list_notifications, mark_all_notifications_read, mark_notification_read as uc_mark_notification_read, @@ -21,26 +22,136 @@ pub async fn list_notifications( per_page: 20, }; let result = uc_list_notifications(&*s.notifications, &uid, page).await?; - Ok(Json( - serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }), - )) + Ok(Json(serde_json::json!({ + "total": result.total, + "unread": result.items.iter().filter(|n| !n.read).count() + }))) } -#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))] +#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))] pub async fn mark_notification_read( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, + Json(body): Json, ) -> Result { - uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?; + if body.read { + uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?; + } Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))] +#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))] pub async fn mark_all_read( State(s): State, AuthUser(uid): AuthUser, + Json(body): Json, ) -> Result { - mark_all_notifications_read(&*s.notifications, &uid).await?; + if body.read { + mark_all_notifications_read(&*s.notifications, &uid).await?; + } Ok(StatusCode::NO_CONTENT) } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::{ + body::Body, + http::{header, Request}, + routing::{get, patch}, + 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(), + } + } + + fn app() -> Router { + Router::new() + .route("/notifications", patch(mark_all_read)) + .route("/notifications/:id", patch(mark_notification_read)) + .with_state(make_state()) + } + + #[tokio::test] + async fn patch_notification_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications/00000000-0000-0000-0000-000000000001") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } + + #[tokio::test] + async fn patch_all_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } +}