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:
@@ -2,7 +2,7 @@ use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
|
||||
};
|
||||
|
||||
use super::movie::Movie;
|
||||
@@ -25,6 +25,16 @@ pub struct PersistedReview {
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub source: ReviewSource,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
}
|
||||
|
||||
/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewEdit {
|
||||
pub rating: Option<Rating>,
|
||||
pub comment: Option<Option<Comment>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -37,6 +47,7 @@ pub struct Review {
|
||||
watched_at: NaiveDateTime,
|
||||
created_at: NaiveDateTime,
|
||||
source: ReviewSource,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
@@ -46,6 +57,7 @@ impl Review {
|
||||
rating: Rating,
|
||||
comment: Option<Comment>,
|
||||
watched_at: NaiveDateTime,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
) -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
id: ReviewId::generate(),
|
||||
@@ -56,6 +68,7 @@ impl Review {
|
||||
watched_at,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,6 +82,27 @@ impl Review {
|
||||
watched_at: row.watched_at,
|
||||
created_at: row.created_at,
|
||||
source: row.source,
|
||||
watch_medium: row.watch_medium,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_edit(&self, edit: ReviewEdit) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
movie_id: self.movie_id.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
rating: edit.rating.unwrap_or_else(|| self.rating.clone()),
|
||||
comment: match edit.comment {
|
||||
Some(c) => c,
|
||||
None => self.comment.clone(),
|
||||
},
|
||||
watched_at: edit.watched_at.unwrap_or(self.watched_at),
|
||||
created_at: self.created_at,
|
||||
source: self.source.clone(),
|
||||
watch_medium: match edit.watch_medium {
|
||||
Some(wm) => wm,
|
||||
None => self.watch_medium,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +130,9 @@ impl Review {
|
||||
pub fn source(&self) -> &ReviewSource {
|
||||
&self.source
|
||||
}
|
||||
pub fn watch_medium(&self) -> Option<&WatchMedium> {
|
||||
self.watch_medium.as_ref()
|
||||
}
|
||||
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
||||
pub fn stars(&self) -> [bool; 5] {
|
||||
let r = self.rating.value();
|
||||
@@ -157,3 +194,7 @@ impl ReviewHistory {
|
||||
self.viewings.sort_by_key(|r| *r.watched_at());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/review.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -78,6 +78,7 @@ fn make_review() -> Review {
|
||||
Rating::new(4).unwrap(),
|
||||
Some(Comment::new("great".into()).unwrap()),
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -112,6 +113,7 @@ fn review_from_persistence() {
|
||||
source: ReviewSource::Remote {
|
||||
actor_url: "https://example.com/actor".into(),
|
||||
},
|
||||
watch_medium: None,
|
||||
});
|
||||
assert_eq!(*r.id(), id);
|
||||
assert!(r.is_remote());
|
||||
|
||||
81
crates/domain/src/models/tests/review.rs
Normal file
81
crates/domain/src/models/tests/review.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_review(watch_medium: Option<WatchMedium>) -> Review {
|
||||
Review::new(
|
||||
MovieId::generate(),
|
||||
UserId::generate(),
|
||||
Rating::new(4).unwrap(),
|
||||
Some(Comment::new("great film".into()).unwrap()),
|
||||
NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
watch_medium,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_review_stores_watch_medium() {
|
||||
let review = make_review(Some(WatchMedium::Cinema));
|
||||
assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_review_without_medium() {
|
||||
let review = make_review(None);
|
||||
assert_eq!(review.watch_medium(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_updates_rating_only() {
|
||||
let original = make_review(Some(WatchMedium::Streaming));
|
||||
let edited = original.apply_edit(ReviewEdit {
|
||||
rating: Some(Rating::new(2).unwrap()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(edited.rating().value(), 2);
|
||||
assert_eq!(edited.comment().unwrap().value(), "great film");
|
||||
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Streaming));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_clears_comment() {
|
||||
let original = make_review(None);
|
||||
let edited = original.apply_edit(ReviewEdit {
|
||||
comment: Some(None),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(edited.comment().is_none());
|
||||
assert_eq!(edited.rating().value(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_sets_watch_medium() {
|
||||
let original = make_review(None);
|
||||
let edited = original.apply_edit(ReviewEdit {
|
||||
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Cinema));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_clears_watch_medium() {
|
||||
let original = make_review(Some(WatchMedium::Cinema));
|
||||
let edited = original.apply_edit(ReviewEdit {
|
||||
watch_medium: Some(None),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(edited.watch_medium(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_no_changes_preserves_all() {
|
||||
let original = make_review(Some(WatchMedium::TV));
|
||||
let edited = original.apply_edit(ReviewEdit::default());
|
||||
assert_eq!(edited.rating().value(), 4);
|
||||
assert_eq!(edited.comment().unwrap().value(), "great film");
|
||||
assert_eq!(edited.watch_medium(), Some(&WatchMedium::TV));
|
||||
}
|
||||
@@ -45,6 +45,7 @@ pub trait DiaryRepository: Send + Sync {
|
||||
pub trait ReviewRepository: Send + Sync {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError>;
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
|
||||
Rating::new(rating).unwrap(),
|
||||
None,
|
||||
watched_at,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -189,6 +189,14 @@ impl ReviewRepository for InMemoryReviewRepository {
|
||||
Ok(self.store.lock().unwrap().get(&review_id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(review.id().value(), review.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().remove(&review_id.value());
|
||||
Ok(())
|
||||
|
||||
@@ -158,3 +158,44 @@ fn password_value_preserves_content() {
|
||||
let raw = "supersecret!".to_string();
|
||||
assert_eq!(Password::new(raw.clone()).unwrap().value(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_medium_parses_valid_strings() {
|
||||
let cases = [
|
||||
("cinema", WatchMedium::Cinema),
|
||||
("streaming", WatchMedium::Streaming),
|
||||
("tv", WatchMedium::TV),
|
||||
("physical_media", WatchMedium::PhysicalMedia),
|
||||
("download", WatchMedium::Download),
|
||||
("media_server", WatchMedium::MediaServer),
|
||||
("other", WatchMedium::Other),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let parsed: WatchMedium = input.parse().unwrap();
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_medium_rejects_invalid() {
|
||||
assert!("nonsense".parse::<WatchMedium>().is_err());
|
||||
assert!("".parse::<WatchMedium>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_medium_display_round_trips() {
|
||||
let variants = [
|
||||
WatchMedium::Cinema,
|
||||
WatchMedium::Streaming,
|
||||
WatchMedium::TV,
|
||||
WatchMedium::PhysicalMedia,
|
||||
WatchMedium::Download,
|
||||
WatchMedium::MediaServer,
|
||||
WatchMedium::Other,
|
||||
];
|
||||
for v in variants {
|
||||
let s = v.to_string();
|
||||
let parsed: WatchMedium = s.parse().unwrap();
|
||||
assert_eq!(parsed, v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WatchMedium {
|
||||
Cinema,
|
||||
Streaming,
|
||||
TV,
|
||||
PhysicalMedia,
|
||||
Download,
|
||||
MediaServer,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl fmt::Display for WatchMedium {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Cinema => write!(f, "cinema"),
|
||||
Self::Streaming => write!(f, "streaming"),
|
||||
Self::TV => write!(f, "tv"),
|
||||
Self::PhysicalMedia => write!(f, "physical_media"),
|
||||
Self::Download => write!(f, "download"),
|
||||
Self::MediaServer => write!(f, "media_server"),
|
||||
Self::Other => write!(f, "other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for WatchMedium {
|
||||
type Err = DomainError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"cinema" => Ok(Self::Cinema),
|
||||
"streaming" => Ok(Self::Streaming),
|
||||
"tv" => Ok(Self::TV),
|
||||
"physical_media" => Ok(Self::PhysicalMedia),
|
||||
"download" => Ok(Self::Download),
|
||||
"media_server" => Ok(Self::MediaServer),
|
||||
"other" => Ok(Self::Other),
|
||||
_ => Err(DomainError::ValidationError(format!(
|
||||
"unknown watch medium: {s}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_watched_at(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
|
||||
.or_else(|_| {
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
|
||||
})
|
||||
.map_err(|_| {
|
||||
DomainError::ValidationError(format!(
|
||||
"invalid date '{s}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn format_watched_at(dt: &chrono::NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Rating(u8);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user