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
164 lines
5.1 KiB
Rust
164 lines
5.1 KiB
Rust
use super::ExportAdapter;
|
|
use domain::{
|
|
models::{DiaryEntry, ExportFormat, Movie, Review},
|
|
ports::DiaryExporter,
|
|
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
|
|
};
|
|
|
|
async fn collect_stream(
|
|
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
|
|
) -> Vec<u8> {
|
|
use futures::StreamExt;
|
|
let mut out = Vec::new();
|
|
futures::pin_mut!(stream);
|
|
while let Some(chunk) = stream.next().await {
|
|
out.extend_from_slice(&chunk.unwrap());
|
|
}
|
|
out
|
|
}
|
|
|
|
fn entry_stream(
|
|
entries: Vec<domain::models::DiaryEntry>,
|
|
) -> futures::stream::BoxStream<
|
|
'static,
|
|
Result<domain::models::DiaryEntry, domain::errors::DomainError>,
|
|
> {
|
|
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
|
|
}
|
|
|
|
fn make_entry(
|
|
title: &str,
|
|
year: u16,
|
|
director: Option<&str>,
|
|
rating: u8,
|
|
comment: Option<&str>,
|
|
) -> DiaryEntry {
|
|
make_entry_full(title, year, director, rating, comment, None)
|
|
}
|
|
|
|
fn make_entry_full(
|
|
title: &str,
|
|
year: u16,
|
|
director: Option<&str>,
|
|
rating: u8,
|
|
comment: Option<&str>,
|
|
external_id: Option<&str>,
|
|
) -> DiaryEntry {
|
|
let movie = Movie::new(
|
|
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
|
|
MovieTitle::new(title.to_string()).unwrap(),
|
|
ReleaseYear::new(year).unwrap(),
|
|
director.map(str::to_string),
|
|
None,
|
|
);
|
|
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
|
|
let review = Review::new(
|
|
movie.id().clone(),
|
|
user_id,
|
|
Rating::new(rating).unwrap(),
|
|
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
|
|
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
|
|
.unwrap()
|
|
.and_hms_opt(0, 0, 0)
|
|
.unwrap(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
DiaryEntry::new(movie, review)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn csv_has_header_and_one_row() {
|
|
let adapter = ExportAdapter;
|
|
let entry = make_entry(
|
|
"Inception",
|
|
2010,
|
|
Some("Christopher Nolan"),
|
|
5,
|
|
Some("great"),
|
|
);
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
|
let text = String::from_utf8(bytes).unwrap();
|
|
assert!(
|
|
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
|
|
);
|
|
assert!(text.contains("Inception"));
|
|
assert!(text.contains("2010"));
|
|
assert!(text.contains("Christopher Nolan"));
|
|
assert!(text.contains("5"));
|
|
assert!(text.contains("great"));
|
|
assert!(text.contains("2024-03-15"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn csv_escapes_commas_in_title() {
|
|
let adapter = ExportAdapter;
|
|
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
|
let text = String::from_utf8(bytes).unwrap();
|
|
assert!(text.contains("\"Tár, A Film\""));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn json_is_valid_array() {
|
|
let adapter = ExportAdapter;
|
|
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
|
|
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(arr.len(), 1);
|
|
assert_eq!(arr[0]["title"], "Dune");
|
|
assert_eq!(arr[0]["year"], 2021);
|
|
assert_eq!(arr[0]["rating"], 5);
|
|
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
|
|
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_metadata_id_included_when_present() {
|
|
let adapter = ExportAdapter;
|
|
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
|
|
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
|
|
|
|
let bytes = collect_stream(adapter.stream_entries(
|
|
entry_stream(vec![make_entry_full(
|
|
"Alien",
|
|
1979,
|
|
None,
|
|
5,
|
|
None,
|
|
Some("tt0078748"),
|
|
)]),
|
|
ExportFormat::Csv,
|
|
))
|
|
.await;
|
|
let text = String::from_utf8(bytes).unwrap();
|
|
assert!(text.contains("tt0078748"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_entries_returns_csv_header_only() {
|
|
let adapter = ExportAdapter;
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Csv)).await;
|
|
let text = String::from_utf8(bytes).unwrap();
|
|
assert_eq!(
|
|
text,
|
|
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_json_is_valid_empty_array() {
|
|
let adapter = ExportAdapter;
|
|
let bytes =
|
|
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Json)).await;
|
|
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
assert!(arr.is_empty());
|
|
}
|