feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
Some checks failed
CI / Check / Test (push) Failing after 27m0s
- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other) - PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium) - edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast - SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge - shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at - deploy.sh parameterized (--features, --tag), CORS allows PATCH - CONTEXT.md glossary, ADR-0001 general review editing
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::value_objects::WatchMedium;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct MovieInput {
|
||||
@@ -15,6 +16,7 @@ pub struct LogReviewCommand {
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
@@ -22,6 +24,15 @@ pub struct DeleteReviewCommand {
|
||||
pub requesting_user_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct EditReviewCommand {
|
||||
pub review_id: Uuid,
|
||||
pub requesting_user_id: Uuid,
|
||||
pub rating: Option<u8>,
|
||||
pub comment: Option<Option<String>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncPosterCommand {
|
||||
pub movie_id: Uuid,
|
||||
|
||||
@@ -14,6 +14,11 @@ pub struct DeleteReviewDeps {
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct EditReviewDeps {
|
||||
pub review: Arc<dyn ReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetMovieSocialPageDeps {
|
||||
pub movie: Arc<dyn MovieRepository>,
|
||||
pub diary: Arc<dyn DiaryRepository>,
|
||||
|
||||
60
crates/application/src/diary/edit_review.rs
Normal file
60
crates/application/src/diary/edit_review.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::ReviewEdit,
|
||||
value_objects::{Comment, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<(), DomainError> {
|
||||
let review_id = ReviewId::from_uuid(cmd.review_id);
|
||||
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
|
||||
|
||||
let review = deps
|
||||
.review
|
||||
.get_review_by_id(&review_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
|
||||
|
||||
if review.is_remote() {
|
||||
return Err(DomainError::Forbidden(
|
||||
"cannot edit a federated review".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if review.user_id() != &requesting_user_id {
|
||||
return Err(DomainError::Forbidden("not your review".into()));
|
||||
}
|
||||
|
||||
let updated = review.apply_edit(ReviewEdit {
|
||||
rating: cmd.rating.map(Rating::new).transpose()?,
|
||||
comment: cmd
|
||||
.comment
|
||||
.map(|c| c.map(Comment::new).transpose())
|
||||
.transpose()?,
|
||||
watched_at: cmd.watched_at,
|
||||
watch_medium: cmd.watch_medium,
|
||||
});
|
||||
|
||||
deps.review.update_review(&updated).await?;
|
||||
|
||||
if let Err(e) = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ReviewUpdated {
|
||||
review_id: updated.id().clone(),
|
||||
movie_id: updated.movie_id().clone(),
|
||||
user_id: updated.user_id().clone(),
|
||||
rating: updated.rating().clone(),
|
||||
watched_at: *updated.watched_at(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to publish ReviewUpdated: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/edit_review.rs"]
|
||||
mod tests;
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod commands;
|
||||
pub mod delete_review;
|
||||
pub mod deps;
|
||||
pub mod edit_review;
|
||||
pub mod export_diary;
|
||||
pub mod get_activity_feed;
|
||||
pub mod get_diary;
|
||||
|
||||
@@ -68,7 +68,14 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
cmd.watched_at,
|
||||
cmd.watch_medium,
|
||||
)?;
|
||||
let review_event = self.review_repo.save_review(&review).await?;
|
||||
|
||||
let was_on_watchlist = self
|
||||
|
||||
@@ -32,6 +32,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
Utc::now().naive_utc(),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
165
crates/application/src/diary/tests/edit_review.rs
Normal file
165
crates/application/src/diary/tests/edit_review.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
models::Review,
|
||||
ports::ReviewRepository,
|
||||
testing::{InMemoryReviewRepository, NoopEventPublisher},
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
|
||||
};
|
||||
|
||||
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
|
||||
|
||||
fn make_review(user_id: UserId) -> Review {
|
||||
Review::new(
|
||||
MovieId::generate(),
|
||||
user_id,
|
||||
Rating::new(3).unwrap(),
|
||||
Some(Comment::new("original comment".into()).unwrap()),
|
||||
Utc::now().naive_utc(),
|
||||
Some(WatchMedium::Streaming),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn setup() -> (
|
||||
Arc<InMemoryReviewRepository>,
|
||||
Arc<NoopEventPublisher>,
|
||||
ReviewId,
|
||||
UserId,
|
||||
) {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let user_id = UserId::generate();
|
||||
let review = make_review(user_id.clone());
|
||||
let review_id = review.id().clone();
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
(reviews, events, review_id, user_id)
|
||||
}
|
||||
|
||||
fn deps(
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> EditReviewDeps {
|
||||
EditReviewDeps {
|
||||
review: Arc::clone(reviews) as _,
|
||||
event_publisher: Arc::clone(events) as _,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_own_review_updates_rating() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: Some(5),
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.rating().value(), 5);
|
||||
assert_eq!(updated.comment().unwrap().value(), "original comment");
|
||||
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nonexistent_review_returns_not_found() {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let err = edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
EditReviewCommand {
|
||||
review_id: Uuid::new_v4(),
|
||||
requesting_user_id: Uuid::new_v4(),
|
||||
rating: Some(5),
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, domain::errors::DomainError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_other_users_review_returns_forbidden() {
|
||||
let (reviews, events, review_id, _user_id) = setup().await;
|
||||
|
||||
let err = edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: Uuid::new_v4(),
|
||||
rating: Some(5),
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_publishes_review_updated_event() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: Some(1),
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert_eq!(published.len(), 1);
|
||||
assert!(matches!(
|
||||
published[0],
|
||||
domain::events::DomainEvent::ReviewUpdated { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_sets_watch_medium() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: None,
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema));
|
||||
}
|
||||
@@ -65,6 +65,7 @@ async fn test_log_review_creates_movie_and_review() {
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -97,6 +98,7 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -118,6 +120,7 @@ async fn test_log_review_with_invalid_rating_fails() {
|
||||
rating: 6,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
let result = log_review::execute(&logger, cmd).await;
|
||||
assert!(result.is_err(), "rating > 5 should fail");
|
||||
|
||||
@@ -55,6 +55,7 @@ async fn logs_review_with_manual_movie() {
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -103,6 +104,7 @@ async fn removes_from_watchlist_on_review() {
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -140,6 +142,7 @@ async fn logs_review_with_existing_movie_by_id() {
|
||||
rating: 3,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -168,6 +171,7 @@ async fn existing_movie_not_found_returns_error() {
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
assert!(logger.log_review(cmd).await.is_err());
|
||||
@@ -193,6 +197,7 @@ async fn invalid_rating_returns_error() {
|
||||
rating: 6,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
let result = logger.log_review(cmd).await;
|
||||
@@ -222,6 +227,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -289,6 +295,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
@@ -118,5 +118,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
|
||||
rating,
|
||||
comment: row.comment.clone(),
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub async fn execute(
|
||||
rating: c.rating,
|
||||
comment: c.comment,
|
||||
watched_at: *event.watched_at(),
|
||||
watch_medium: None,
|
||||
};
|
||||
|
||||
review_logger.log_review(review_cmd).await?;
|
||||
|
||||
Reference in New Issue
Block a user