refactor: typed errors in rss/import ports
This commit is contained in:
@@ -60,73 +60,5 @@ struct JellyfinPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/lib.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_valid_playback_stop() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"NotificationType": "PlaybackStop",
|
|
||||||
"ItemType": "Movie",
|
|
||||||
"Name": "Blade Runner",
|
|
||||||
"Year": 1982,
|
|
||||||
"PlayedToCompletion": true,
|
|
||||||
"Provider_tmdb": "78",
|
|
||||||
"Provider_imdb": "tt0083658"
|
|
||||||
});
|
|
||||||
let parser = JellyfinParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
let event = result.expect("should parse");
|
|
||||||
assert_eq!(event.title, "Blade Runner");
|
|
||||||
assert_eq!(event.year, Some(1982));
|
|
||||||
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
|
||||||
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_non_movie() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"NotificationType": "PlaybackStop",
|
|
||||||
"ItemType": "Episode",
|
|
||||||
"Name": "Some Episode",
|
|
||||||
"PlayedToCompletion": true
|
|
||||||
});
|
|
||||||
let parser = JellyfinParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_incomplete_playback() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"NotificationType": "PlaybackStop",
|
|
||||||
"ItemType": "Movie",
|
|
||||||
"Name": "Blade Runner",
|
|
||||||
"PlayedToCompletion": false
|
|
||||||
});
|
|
||||||
let parser = JellyfinParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_playback_start() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"NotificationType": "PlaybackStart",
|
|
||||||
"ItemType": "Movie",
|
|
||||||
"Name": "Blade Runner",
|
|
||||||
"PlayedToCompletion": false
|
|
||||||
});
|
|
||||||
let parser = JellyfinParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
68
crates/adapters/jellyfin/src/tests/lib.rs
Normal file
68
crates/adapters/jellyfin/src/tests/lib.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_valid_playback_stop() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"NotificationType": "PlaybackStop",
|
||||||
|
"ItemType": "Movie",
|
||||||
|
"Name": "Blade Runner",
|
||||||
|
"Year": 1982,
|
||||||
|
"PlayedToCompletion": true,
|
||||||
|
"Provider_tmdb": "78",
|
||||||
|
"Provider_imdb": "tt0083658"
|
||||||
|
});
|
||||||
|
let parser = JellyfinParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
let event = result.expect("should parse");
|
||||||
|
assert_eq!(event.title, "Blade Runner");
|
||||||
|
assert_eq!(event.year, Some(1982));
|
||||||
|
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
||||||
|
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_movie() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"NotificationType": "PlaybackStop",
|
||||||
|
"ItemType": "Episode",
|
||||||
|
"Name": "Some Episode",
|
||||||
|
"PlayedToCompletion": true
|
||||||
|
});
|
||||||
|
let parser = JellyfinParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_incomplete_playback() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"NotificationType": "PlaybackStop",
|
||||||
|
"ItemType": "Movie",
|
||||||
|
"Name": "Blade Runner",
|
||||||
|
"PlayedToCompletion": false
|
||||||
|
});
|
||||||
|
let parser = JellyfinParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_playback_start() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"NotificationType": "PlaybackStart",
|
||||||
|
"ItemType": "Movie",
|
||||||
|
"Name": "Blade Runner",
|
||||||
|
"PlayedToCompletion": false
|
||||||
|
});
|
||||||
|
let parser = JellyfinParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
@@ -70,100 +70,5 @@ struct PlexGuid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/lib.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_movie_scrobble() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"event": "media.scrobble",
|
|
||||||
"Metadata": {
|
|
||||||
"type": "movie",
|
|
||||||
"title": "Blade Runner",
|
|
||||||
"year": 1982,
|
|
||||||
"Guid": [
|
|
||||||
{"id": "tmdb://78"},
|
|
||||||
{"id": "imdb://tt0083658"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let parser = PlexParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
let event = result.expect("should parse");
|
|
||||||
assert_eq!(event.title, "Blade Runner");
|
|
||||||
assert_eq!(event.year, Some(1982));
|
|
||||||
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
|
||||||
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_tv_episode() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"event": "media.scrobble",
|
|
||||||
"Metadata": {
|
|
||||||
"type": "episode",
|
|
||||||
"title": "Pilot",
|
|
||||||
"grandparentTitle": "Breaking Bad",
|
|
||||||
"year": 2008,
|
|
||||||
"Guid": []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let parser = PlexParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_play_event() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"event": "media.play",
|
|
||||||
"Metadata": {
|
|
||||||
"type": "movie",
|
|
||||||
"title": "Blade Runner",
|
|
||||||
"year": 1982,
|
|
||||||
"Guid": []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let parser = PlexParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handles_no_guids() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"event": "media.scrobble",
|
|
||||||
"Metadata": {
|
|
||||||
"type": "movie",
|
|
||||||
"title": "Some Indie Film",
|
|
||||||
"year": 2023
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let parser = PlexParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
let event = result.expect("should parse");
|
|
||||||
assert_eq!(event.title, "Some Indie Film");
|
|
||||||
assert!(event.tmdb_id.is_none());
|
|
||||||
assert!(event.imdb_id.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handles_missing_metadata() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"event": "media.scrobble"
|
|
||||||
});
|
|
||||||
let parser = PlexParser;
|
|
||||||
let result = parser
|
|
||||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
95
crates/adapters/plex/src/tests/lib.rs
Normal file
95
crates/adapters/plex/src/tests/lib.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_movie_scrobble() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"event": "media.scrobble",
|
||||||
|
"Metadata": {
|
||||||
|
"type": "movie",
|
||||||
|
"title": "Blade Runner",
|
||||||
|
"year": 1982,
|
||||||
|
"Guid": [
|
||||||
|
{"id": "tmdb://78"},
|
||||||
|
{"id": "imdb://tt0083658"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let parser = PlexParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
let event = result.expect("should parse");
|
||||||
|
assert_eq!(event.title, "Blade Runner");
|
||||||
|
assert_eq!(event.year, Some(1982));
|
||||||
|
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
||||||
|
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_tv_episode() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"event": "media.scrobble",
|
||||||
|
"Metadata": {
|
||||||
|
"type": "episode",
|
||||||
|
"title": "Pilot",
|
||||||
|
"grandparentTitle": "Breaking Bad",
|
||||||
|
"year": 2008,
|
||||||
|
"Guid": []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let parser = PlexParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_play_event() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"event": "media.play",
|
||||||
|
"Metadata": {
|
||||||
|
"type": "movie",
|
||||||
|
"title": "Blade Runner",
|
||||||
|
"year": 1982,
|
||||||
|
"Guid": []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let parser = PlexParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_no_guids() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"event": "media.scrobble",
|
||||||
|
"Metadata": {
|
||||||
|
"type": "movie",
|
||||||
|
"title": "Some Indie Film",
|
||||||
|
"year": 2023
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let parser = PlexParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
let event = result.expect("should parse");
|
||||||
|
assert_eq!(event.title, "Some Indie Film");
|
||||||
|
assert!(event.tmdb_id.is_none());
|
||||||
|
assert!(event.imdb_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_missing_metadata() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"event": "media.scrobble"
|
||||||
|
});
|
||||||
|
let parser = PlexParser;
|
||||||
|
let result = parser
|
||||||
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
use domain::models::DiaryEntry;
|
use domain::models::DiaryEntry;
|
||||||
use domain::ports::{FeedChannel, RssFeedRenderer};
|
use domain::ports::{FeedChannel, RssFeedRenderer};
|
||||||
use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link};
|
use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link};
|
||||||
@@ -24,7 +25,7 @@ impl RssFeedRenderer for RssAdapter {
|
|||||||
&self,
|
&self,
|
||||||
entries: &[DiaryEntry],
|
entries: &[DiaryEntry],
|
||||||
channel: &FeedChannel<'_>,
|
channel: &FeedChannel<'_>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, DomainError> {
|
||||||
let items = entries
|
let items = entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
|||||||
@@ -78,28 +78,6 @@ pub struct PendingCountResponse {
|
|||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
//todo: move out to separate file
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/social.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn follow_state_dto_serializes_to_lowercase_strings() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&FollowStateDto::None).unwrap(),
|
|
||||||
"\"none\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&FollowStateDto::Pending).unwrap(),
|
|
||||||
"\"pending\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&FollowStateDto::Accepted).unwrap(),
|
|
||||||
"\"accepted\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&FollowStateDto::Rejected).unwrap(),
|
|
||||||
"\"rejected\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
21
crates/api-types/src/tests/social.rs
Normal file
21
crates/api-types/src/tests/social.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_state_dto_serializes_to_lowercase_strings() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::None).unwrap(),
|
||||||
|
"\"none\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Pending).unwrap(),
|
||||||
|
"\"pending\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Accepted).unwrap(),
|
||||||
|
"\"accepted\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Rejected).unwrap(),
|
||||||
|
"\"rejected\""
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,7 +44,8 @@ pub async fn execute(
|
|||||||
let mut failed = Vec::new();
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY_LIMIT));
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY_LIMIT));
|
||||||
let mut tasks: tokio::task::JoinSet<(usize, Result<(), String>)> = tokio::task::JoinSet::new();
|
let mut tasks: tokio::task::JoinSet<(usize, Result<(), DomainError>)> =
|
||||||
|
tokio::task::JoinSet::new();
|
||||||
|
|
||||||
for (idx, annotated) in row_results.into_iter().enumerate() {
|
for (idx, annotated) in row_results.into_iter().enumerate() {
|
||||||
if !confirmed_set.contains(&idx) {
|
if !confirmed_set.contains(&idx) {
|
||||||
@@ -56,12 +57,12 @@ pub async fn execute(
|
|||||||
failed.push((idx, errors.join("; ")));
|
failed.push((idx, errors.join("; ")));
|
||||||
}
|
}
|
||||||
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
|
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
|
||||||
Err(e) => failed.push((idx, e)),
|
Err(e) => failed.push((idx, import_error_message(e))),
|
||||||
Ok(log_cmd) => {
|
Ok(log_cmd) => {
|
||||||
let permit = Arc::clone(&semaphore).acquire_owned().await.unwrap();
|
let permit = Arc::clone(&semaphore).acquire_owned().await.unwrap();
|
||||||
let logger = deps.review_logger.clone();
|
let logger = deps.review_logger.clone();
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
let result = logger.log_review(log_cmd).await.map_err(|e| e.to_string());
|
let result = logger.log_review(log_cmd).await;
|
||||||
drop(permit);
|
drop(permit);
|
||||||
(idx, result)
|
(idx, result)
|
||||||
});
|
});
|
||||||
@@ -74,7 +75,7 @@ pub async fn execute(
|
|||||||
let (idx, outcome) = res.expect("import task panicked");
|
let (idx, outcome) = res.expect("import task panicked");
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(()) => imported += 1,
|
Ok(()) => imported += 1,
|
||||||
Err(e) => failed.push((idx, e)),
|
Err(e) => failed.push((idx, e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,20 +92,36 @@ pub async fn execute(
|
|||||||
#[path = "tests/execute.rs"]
|
#[path = "tests/execute.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
|
/// Row-level validation failures keep their bare message in the summary.
|
||||||
|
fn import_error_message(e: DomainError) -> String {
|
||||||
|
match e {
|
||||||
|
DomainError::ValidationError(msg) => msg,
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, DomainError> {
|
||||||
let rating = row
|
let rating = row
|
||||||
.rating
|
.rating
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or("missing rating")?
|
.ok_or_else(|| DomainError::ValidationError("missing rating".into()))?
|
||||||
.parse::<u8>()
|
.parse::<u8>()
|
||||||
.map_err(|_| "rating is not a valid u8".to_string())?;
|
.map_err(|_| DomainError::ValidationError("rating is not a valid u8".into()))?;
|
||||||
|
|
||||||
let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?;
|
let watched_at_str = row
|
||||||
|
.watched_at
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| DomainError::ValidationError("missing watched_at".into()))?;
|
||||||
let watched_at =
|
let watched_at =
|
||||||
NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S")
|
NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S")
|
||||||
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S"))
|
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S"))
|
||||||
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S"))
|
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S"))
|
||||||
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
|
.map_err(|_| {
|
||||||
|
DomainError::ValidationError(format!(
|
||||||
|
"cannot parse watched_at: '{}'",
|
||||||
|
watched_at_str
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(LogReviewCommand {
|
Ok(LogReviewCommand {
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -38,43 +38,5 @@ pub(super) fn paged_user_filter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/diary_filter.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_sort_to_direction_all_variants() {
|
|
||||||
assert!(matches!(
|
|
||||||
feed_sort_to_direction(FeedSortBy::Date),
|
|
||||||
ReviewSortBy::Descending
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
|
||||||
ReviewSortBy::Ascending
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
feed_sort_to_direction(FeedSortBy::Rating),
|
|
||||||
ReviewSortBy::ByRatingDesc
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
|
||||||
ReviewSortBy::ByRatingAsc
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paged_user_filter_builds_correctly() {
|
|
||||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
let filter = paged_user_filter(
|
|
||||||
uid.clone(),
|
|
||||||
ReviewSortBy::Descending,
|
|
||||||
Some(20),
|
|
||||||
Some(5),
|
|
||||||
Some("blade".into()),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
|
||||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
38
crates/application/src/users/tests/diary_filter.rs
Normal file
38
crates/application/src/users/tests/diary_filter.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feed_sort_to_direction_all_variants() {
|
||||||
|
assert!(matches!(
|
||||||
|
feed_sort_to_direction(FeedSortBy::Date),
|
||||||
|
ReviewSortBy::Descending
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||||
|
ReviewSortBy::Ascending
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
feed_sort_to_direction(FeedSortBy::Rating),
|
||||||
|
ReviewSortBy::ByRatingDesc
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||||
|
ReviewSortBy::ByRatingAsc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paged_user_filter_builds_correctly() {
|
||||||
|
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
let filter = paged_user_filter(
|
||||||
|
uid.clone(),
|
||||||
|
ReviewSortBy::Descending,
|
||||||
|
Some(20),
|
||||||
|
Some(5),
|
||||||
|
Some("blade".into()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||||
|
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||||
|
}
|
||||||
@@ -51,47 +51,5 @@ pub trait ApBackfillPort: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/federation.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn noop_document_returns_empty_strings() {
|
|
||||||
let p = NoopApDocument;
|
|
||||||
assert_eq!(p.actor_json("anything").await.unwrap(), "");
|
|
||||||
assert_eq!(
|
|
||||||
p.followers_collection_json(uuid::Uuid::nil(), None)
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
p.following_collection_json(uuid::Uuid::nil(), Some(2))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn noop_blocklist_returns_empty_and_ok() {
|
|
||||||
let p = NoopInstanceBlocklist;
|
|
||||||
assert!(p.get_blocked_domains().await.unwrap().is_empty());
|
|
||||||
p.add_blocked_domain("evil.example", Some("spam"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
p.remove_blocked_domain("evil.example").await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn noop_backfill_returns_empty_and_ok() {
|
|
||||||
let p = NoopApBackfill;
|
|
||||||
assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty());
|
|
||||||
p.import_remote_outbox("https://a/outbox", "https://a")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
use crate::models::DiaryEntry;
|
use crate::models::DiaryEntry;
|
||||||
|
|
||||||
/// Channel-level metadata for a rendered feed. Paths are site-relative
|
/// Channel-level metadata for a rendered feed. Paths are site-relative
|
||||||
@@ -15,5 +16,5 @@ pub trait RssFeedRenderer: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
entries: &[DiaryEntry],
|
entries: &[DiaryEntry],
|
||||||
channel: &FeedChannel<'_>,
|
channel: &FeedChannel<'_>,
|
||||||
) -> Result<String, String>;
|
) -> Result<String, DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
42
crates/domain/src/ports/tests/federation.rs
Normal file
42
crates/domain/src/ports/tests/federation.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn noop_document_returns_empty_strings() {
|
||||||
|
let p = NoopApDocument;
|
||||||
|
assert_eq!(p.actor_json("anything").await.unwrap(), "");
|
||||||
|
assert_eq!(
|
||||||
|
p.followers_collection_json(uuid::Uuid::nil(), None)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.following_collection_json(uuid::Uuid::nil(), Some(2))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn noop_blocklist_returns_empty_and_ok() {
|
||||||
|
let p = NoopInstanceBlocklist;
|
||||||
|
assert!(p.get_blocked_domains().await.unwrap().is_empty());
|
||||||
|
p.add_blocked_domain("evil.example", Some("spam"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
p.remove_blocked_domain("evil.example").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn noop_backfill_returns_empty_and_ok() {
|
||||||
|
let p = NoopApBackfill;
|
||||||
|
assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty());
|
||||||
|
p.import_remote_outbox("https://a/outbox", "https://a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
|
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
|
||||||
use domain::{errors::DomainError, models::ReviewSortBy, ports::FeedChannel};
|
use domain::{models::ReviewSortBy, ports::FeedChannel};
|
||||||
|
|
||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse
|
|||||||
self_path: "/feed.rss",
|
self_path: "/feed.rss",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
.map_err(ApiError)?;
|
||||||
Ok((
|
Ok((
|
||||||
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
|
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
|
||||||
xml,
|
xml,
|
||||||
@@ -61,7 +61,7 @@ pub async fn get_user_feed(
|
|||||||
self_path: &self_path,
|
self_path: &self_path,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
.map_err(ApiError)?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
|
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
|
||||||
|
|||||||
@@ -1091,105 +1091,5 @@ pub async fn post_unblock_actor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/social.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
|
|
||||||
|
|
||||||
fn local_actor(uid: &UserId) -> SocialActor {
|
|
||||||
SocialActor {
|
|
||||||
identity: SocialIdentity::Local(uid.clone()),
|
|
||||||
handle: "@gabriel@md.example".into(),
|
|
||||||
display_name: None,
|
|
||||||
avatar_url: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
|
|
||||||
let dto = social_actor_to_dto(local_actor(&uid), &instance);
|
|
||||||
|
|
||||||
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
|
||||||
assert_eq!(
|
|
||||||
instance.identify(&dto.url),
|
|
||||||
SocialIdentity::Local(uid),
|
|
||||||
"a local actor's dto url must parse back as Local"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
#[test]
|
|
||||||
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
|
|
||||||
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
|
|
||||||
|
|
||||||
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
|
||||||
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_template_url_round_trips_back_to_the_same_identity() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
|
|
||||||
let data = social_actor_to_template(local_actor(&uid), &instance);
|
|
||||||
|
|
||||||
assert!(!data.url.starts_with("local:"), "url was {}", data.url);
|
|
||||||
assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_actor_dto_url_is_passed_through_unchanged() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let actor = SocialActor {
|
|
||||||
identity: SocialIdentity::Remote {
|
|
||||||
actor_url: "https://other.example/users/bob".into(),
|
|
||||||
},
|
|
||||||
handle: "@bob@other.example".into(),
|
|
||||||
display_name: None,
|
|
||||||
avatar_url: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let dto = social_actor_to_dto(actor, &instance);
|
|
||||||
|
|
||||||
assert_eq!(dto.url, "https://other.example/users/bob");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_actor_dto_carries_user_id_and_avatar() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
let mut actor = local_actor(&uid);
|
|
||||||
actor.avatar_url = Some("https://md.example/images/a.webp".into());
|
|
||||||
|
|
||||||
let dto = social_actor_to_dto(actor, &instance);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
dto.user_id,
|
|
||||||
Some(uid.value()),
|
|
||||||
"local actors must carry user_id for internal links"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dto.avatar_url.as_deref(),
|
|
||||||
Some("https://md.example/images/a.webp")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_actor_dto_has_no_user_id() {
|
|
||||||
let instance = InstanceIdentity::new("https://md.example");
|
|
||||||
let actor = SocialActor {
|
|
||||||
identity: SocialIdentity::Remote {
|
|
||||||
actor_url: "https://other.example/users/bob".into(),
|
|
||||||
},
|
|
||||||
handle: "@bob@other.example".into(),
|
|
||||||
display_name: None,
|
|
||||||
avatar_url: None,
|
|
||||||
};
|
|
||||||
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
100
crates/presentation/src/handlers/tests/social.rs
Normal file
100
crates/presentation/src/handlers/tests/social.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use super::*;
|
||||||
|
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
|
||||||
|
|
||||||
|
fn local_actor(uid: &UserId) -> SocialActor {
|
||||||
|
SocialActor {
|
||||||
|
identity: SocialIdentity::Local(uid.clone()),
|
||||||
|
handle: "@gabriel@md.example".into(),
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
let dto = social_actor_to_dto(local_actor(&uid), &instance);
|
||||||
|
|
||||||
|
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
||||||
|
assert_eq!(
|
||||||
|
instance.identify(&dto.url),
|
||||||
|
SocialIdentity::Local(uid),
|
||||||
|
"a local actor's dto url must parse back as Local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
#[test]
|
||||||
|
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
|
||||||
|
|
||||||
|
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
||||||
|
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_template_url_round_trips_back_to_the_same_identity() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
let data = social_actor_to_template(local_actor(&uid), &instance);
|
||||||
|
|
||||||
|
assert!(!data.url.starts_with("local:"), "url was {}", data.url);
|
||||||
|
assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_actor_dto_url_is_passed_through_unchanged() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let actor = SocialActor {
|
||||||
|
identity: SocialIdentity::Remote {
|
||||||
|
actor_url: "https://other.example/users/bob".into(),
|
||||||
|
},
|
||||||
|
handle: "@bob@other.example".into(),
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dto = social_actor_to_dto(actor, &instance);
|
||||||
|
|
||||||
|
assert_eq!(dto.url, "https://other.example/users/bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_actor_dto_carries_user_id_and_avatar() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
let mut actor = local_actor(&uid);
|
||||||
|
actor.avatar_url = Some("https://md.example/images/a.webp".into());
|
||||||
|
|
||||||
|
let dto = social_actor_to_dto(actor, &instance);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dto.user_id,
|
||||||
|
Some(uid.value()),
|
||||||
|
"local actors must carry user_id for internal links"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dto.avatar_url.as_deref(),
|
||||||
|
Some("https://md.example/images/a.webp")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_actor_dto_has_no_user_id() {
|
||||||
|
let instance = InstanceIdentity::new("https://md.example");
|
||||||
|
let actor = SocialActor {
|
||||||
|
identity: SocialIdentity::Remote {
|
||||||
|
actor_url: "https://other.example/users/bob".into(),
|
||||||
|
},
|
||||||
|
handle: "@bob@other.example".into(),
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
};
|
||||||
|
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
|
||||||
|
}
|
||||||
@@ -420,7 +420,7 @@ impl domain::ports::RssFeedRenderer for Panic {
|
|||||||
&self,
|
&self,
|
||||||
_: &[DiaryEntry],
|
_: &[DiaryEntry],
|
||||||
_: &domain::ports::FeedChannel<'_>,
|
_: &domain::ports::FeedChannel<'_>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user