feat: add WatchMedium field, general review editing, configurable deploy
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:
2026-07-10 00:01:14 +02:00
parent 9794babe06
commit 29cc68b07c
72 changed files with 1298 additions and 124 deletions

View File

@@ -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()
}

View 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));
}

View File

@@ -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");

View File

@@ -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();