fix: fall back to the default when a servarr returns an unknown enum value

A Servarr build can emit an enum value this app has no name for, either
as a string it does not recognise or as a bare integer for a value the
build itself cannot name. Until now any such value in a response failed
the whole payload, so one unexpected artist status, download status,
task name, series type, minimum availability, credit type, or security
setting emptied the entire view with "Failed to parse response!".

Every enum-typed field on a struct the app only ever deserializes and
displays now uses from_json_or_default, so an unrecognised value
degrades to that enum's default and the rest of the record is kept.
Known values still map exactly as before and serialization is untouched.

Fields on request bodies (Add*Body, Add*Options, Edit*Params, the root
folder bodies) are deliberately left alone: those structs are only ever
serialized, so the attribute would be meaningless there. No candidate
struct is round-tripped: every edit and toggle in the app re-fetches
the raw JSON value and patches it by key, so the fallback can never be
written back to a server as a real setting.

The one Option field, the security config's authentication_required,
also carries serde(default), because deserialize_with on an Option turns
a missing key into a hard error. An unknown value there yields None.

Verified by mutation. Removing the attribute from one field per tree
fails exactly that field's test and nothing else. Dropping default from
the Option field fails only the missing-key test with "missing field
authenticationRequired"; dropping deserialize_with from it fails only
the unknown-value test. All 21 new fallback tests fail against the
unguarded code with "invalid type: integer 2, expected string or map".
This commit is contained in:
Dark-Alex-17 committed 2026-09-17 15:08:52 -06:00
1 parent 9d0973352f
commit a68de41025
10 files changed
+529 -6

No files matched your search

+5
View File
@@ -27,6 +27,7 @@ pub struct Artist {
pub id: i64, pub id: i64,
pub artist_name: HorizontallyScrollableText, pub artist_name: HorizontallyScrollableText,
pub foreign_artist_id: String, pub foreign_artist_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus, pub status: ArtistStatus,
pub overview: Option<String>, pub overview: Option<String>,
pub artist_type: Option<String>, pub artist_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Artist {
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64, pub metadata_profile_id: i64,
pub monitored: bool, pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType, pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>, pub genres: Vec<String>,
pub tags: Vec<Number>, pub tags: Vec<Number>,
@@ -161,6 +163,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadRecord { pub struct DownloadRecord {
pub title: String, pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus, pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub id: i64, pub id: i64,
@@ -219,6 +222,7 @@ pub struct DownloadsResponse {
pub struct AddArtistSearchResult { pub struct AddArtistSearchResult {
pub foreign_artist_id: String, pub foreign_artist_id: String,
pub artist_name: HorizontallyScrollableText, pub artist_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus, pub status: ArtistStatus,
pub overview: Option<String>, pub overview: Option<String>,
pub artist_type: Option<String>, pub artist_type: Option<String>,
@@ -414,6 +418,7 @@ pub struct LidarrHistoryItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LidarrTask { pub struct LidarrTask {
pub name: String, pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: LidarrTaskName, pub task_name: LidarrTaskName,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub interval: i64, pub interval: i64,
+108 -2
View File
@@ -2,13 +2,14 @@
mod tests { mod tests {
use chrono::Utc; use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::models::lidarr_models::{ use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord,
DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MonitorType, LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member,
NewItemMonitorType, Track, TrackFile, MonitorType, NewItemMonitorType, Track, TrackFile,
}; };
use crate::models::servarr_models::{ use crate::models::servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse,
@@ -19,6 +20,9 @@ mod tests {
Serdeable, Serdeable,
lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings},
}; };
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, download_record, task,
};
#[test] #[test]
fn test_artist_status_default() { fn test_artist_status_default() {
@@ -946,6 +950,108 @@ mod tests {
assert_none!(&search_result.ratings); assert_none!(&search_result.ratings);
} }
#[test]
fn test_artist_status_deserialization_falls_back_to_default() {
let expected = Artist {
status: ArtistStatus::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_artist_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Artist {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_artist_search_result_status_deserialization_falls_back_to_default() {
let expected = AddArtistSearchResult {
status: ArtistStatus::default(),
..serde_json::from_str(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_lidarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = LidarrTask {
task_name: LidarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType { fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType {
let history_item_json = json!({ let history_item_json = json!({
"id": 1, "id": 1,
+7 -1
View File
@@ -98,6 +98,7 @@ pub struct Collection {
pub search_on_add: bool, pub search_on_add: bool,
pub monitored: bool, pub monitored: bool,
pub overview: Option<String>, pub overview: Option<String>,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability, pub minimum_availability: MinimumAvailability,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64, pub quality_profile_id: i64,
@@ -126,7 +127,10 @@ pub struct Credit {
pub character: Option<String>, pub character: Option<String>,
pub department: Option<String>, pub department: Option<String>,
pub job: Option<String>, pub job: Option<String>,
#[serde(rename(deserialize = "type"))] #[serde(
rename(deserialize = "type"),
deserialize_with = "super::from_json_or_default"
)]
pub credit_type: CreditType, pub credit_type: CreditType,
} }
@@ -316,6 +320,7 @@ pub struct Movie {
pub tmdb_id: i64, pub tmdb_id: i64,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64, pub quality_profile_id: i64,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability, pub minimum_availability: MinimumAvailability,
pub certification: Option<String>, pub certification: Option<String>,
pub tags: Vec<Number>, pub tags: Vec<Number>,
@@ -469,6 +474,7 @@ pub struct RadarrHistoryItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RadarrTask { pub struct RadarrTask {
pub name: String, pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: RadarrTaskName, pub task_name: RadarrTaskName,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub interval: i64, pub interval: i64,
+88 -2
View File
@@ -1,6 +1,7 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::models::radarr_models::{ use crate::models::radarr_models::{
@@ -9,8 +10,8 @@ mod tests {
use crate::models::{ use crate::models::{
Serdeable, Serdeable,
radarr_models::{ radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType,
DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease,
RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update,
}, },
@@ -18,6 +19,9 @@ mod tests {
HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus, HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus,
}, },
}; };
use crate::network::radarr_network::radarr_network_test_utils::test_utils::{
MOVIE_JSON, collection, crew_credit, task,
};
#[test] #[test]
fn test_task_name_display() { fn test_task_name_display() {
@@ -614,6 +618,88 @@ mod tests {
); );
} }
#[test]
fn test_movie_minimum_availability_deserialization_falls_back_to_default() {
let expected = Movie {
minimum_availability: MinimumAvailability::default(),
..serde_json::from_str(MOVIE_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!("notAThing")),
expected
);
}
#[test]
fn test_collection_minimum_availability_deserialization_falls_back_to_default() {
let collection_json = serde_json::to_string(&collection()).unwrap();
let expected = Collection {
minimum_availability: MinimumAvailability::default(),
..collection()
};
assert_eq!(
deserialize_with_field::<Collection>(&collection_json, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Collection>(
&collection_json,
"minimumAvailability",
json!("notAThing")
),
expected
);
}
#[test]
fn test_credit_credit_type_deserialization_falls_back_to_default() {
let credit_json = serde_json::to_string(&crew_credit()).unwrap();
let expected = Credit {
credit_type: CreditType::default(),
..crew_credit()
};
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!("notAThing")),
expected
);
}
#[test]
fn test_radarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = RadarrTask {
task_name: RadarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType { fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType {
let history_item_json = json!({ let history_item_json = json!({
"id": 1, "id": 1,
+5
View File
@@ -27,6 +27,7 @@ pub struct Author {
pub id: i64, pub id: i64,
pub author_name: HorizontallyScrollableText, pub author_name: HorizontallyScrollableText,
pub foreign_author_id: String, pub foreign_author_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus, pub status: AuthorStatus,
pub overview: Option<String>, pub overview: Option<String>,
pub author_type: Option<String>, pub author_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Author {
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64, pub metadata_profile_id: i64,
pub monitored: bool, pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType, pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>, pub genres: Vec<String>,
pub tags: Vec<Number>, pub tags: Vec<Number>,
@@ -152,6 +154,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadRecord { pub struct DownloadRecord {
pub title: String, pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus, pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub id: i64, pub id: i64,
@@ -210,6 +213,7 @@ pub struct DownloadsResponse {
pub struct AddAuthorSearchResult { pub struct AddAuthorSearchResult {
pub foreign_author_id: String, pub foreign_author_id: String,
pub author_name: HorizontallyScrollableText, pub author_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus, pub status: AuthorStatus,
#[serde(default)] #[serde(default)]
pub ended: bool, pub ended: bool,
@@ -423,6 +427,7 @@ pub struct ReadarrHistoryItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ReadarrTask { pub struct ReadarrTask {
pub name: String, pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: ReadarrTaskName, pub task_name: ReadarrTaskName,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub interval: i64, pub interval: i64,
+111
View File
@@ -2,6 +2,7 @@
mod tests { mod tests {
use chrono::Utc; use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::models::readarr_models::{ use crate::models::readarr_models::{
@@ -19,6 +20,9 @@ mod tests {
Serdeable, Serdeable,
readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable}, readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable},
}; };
use crate::network::readarr_network::readarr_network_test_utils::test_utils::{
ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, download_record,
};
#[test] #[test]
fn test_author_status_default() { fn test_author_status_default() {
@@ -1140,6 +1144,113 @@ mod tests {
assert_none!(&search_result.ratings); assert_none!(&search_result.ratings);
} }
#[test]
fn test_author_status_deserialization_falls_back_to_default() {
let expected = Author {
status: AuthorStatus::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_author_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Author {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_author_search_result_status_deserialization_falls_back_to_default() {
let expected = AddAuthorSearchResult {
status: AuthorStatus::default(),
..serde_json::from_str(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_readarr_task_task_name_deserialization_falls_back_to_default() {
let task = ReadarrTask {
name: "Backup".to_owned(),
task_name: ReadarrTaskName::Backup,
..ReadarrTask::default()
};
let task_json = serde_json::to_string(&task).unwrap();
let expected = ReadarrTask {
task_name: ReadarrTaskName::default(),
..task
};
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType { fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType {
let history_item_json = json!({ let history_item_json = json!({
"id": 1, "id": 1,
+3
View File
@@ -315,7 +315,9 @@ pub struct RootFolder {
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SecurityConfig { pub struct SecurityConfig {
#[serde(deserialize_with = "super::from_json_or_default")]
pub authentication_method: AuthenticationMethod, pub authentication_method: AuthenticationMethod,
#[serde(default, deserialize_with = "super::from_json_or_default")]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub authentication_required: Option<AuthenticationRequired>, pub authentication_required: Option<AuthenticationRequired>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -323,6 +325,7 @@ pub struct SecurityConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>, pub password: Option<String>,
pub api_key: String, pub api_key: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub certificate_validation: CertificateValidation, pub certificate_validation: CertificateValidation,
} }
+117 -1
View File
@@ -1,10 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::servarr_models::{ use crate::models::servarr_models::{
AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile, AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile,
Update, UpdateChanges, SecurityConfig, Update, UpdateChanges,
}; };
#[test] #[test]
@@ -90,4 +92,118 @@ mod tests {
} }
); );
} }
#[test]
fn test_security_config_authentication_method_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_method: AuthenticationMethod::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_authentication_required_deserialization_falls_back_to_none() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_deserializes_when_authentication_required_is_absent() {
let mut security_config_json = serde_json::to_value(security_config()).unwrap();
security_config_json
.as_object_mut()
.unwrap()
.remove("authenticationRequired");
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
let deserialized = serde_json::from_value::<SecurityConfig>(security_config_json);
assert_ok!(&deserialized);
assert_eq!(deserialized.unwrap(), expected);
}
#[test]
fn test_security_config_certificate_validation_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
certificate_validation: CertificateValidation::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!("notAThing")
),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn security_config() -> SecurityConfig {
SecurityConfig {
authentication_method: AuthenticationMethod::Forms,
authentication_required: Some(AuthenticationRequired::Enabled),
username: Some("admin".to_owned()),
password: Some("password".to_owned()),
api_key: "test-api-key".to_owned(),
certificate_validation: CertificateValidation::Disabled,
}
}
} }
+4
View File
@@ -112,6 +112,7 @@ pub struct DeleteSeriesParams {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadRecord { pub struct DownloadRecord {
pub title: String, pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus, pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub id: i64, pub id: i64,
@@ -314,12 +315,14 @@ pub struct Series {
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub year: i64, pub year: i64,
pub monitored: bool, pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub series_type: SeriesType, pub series_type: SeriesType,
pub path: String, pub path: String,
pub genres: Vec<String>, pub genres: Vec<String>,
pub tags: Vec<Number>, pub tags: Vec<Number>,
pub ratings: Rating, pub ratings: Rating,
pub ended: bool, pub ended: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: SeriesStatus, pub status: SeriesStatus,
pub overview: Option<String>, pub overview: Option<String>,
pub network: Option<String>, pub network: Option<String>,
@@ -556,6 +559,7 @@ pub struct SonarrReleaseDownloadBody {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SonarrTask { pub struct SonarrTask {
pub name: String, pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: SonarrTaskName, pub task_name: SonarrTaskName,
#[serde(deserialize_with = "super::from_i64")] #[serde(deserialize_with = "super::from_i64")]
pub interval: i64, pub interval: i64,
+81
View File
@@ -1,6 +1,7 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::models::{ use crate::models::{
@@ -17,6 +18,9 @@ mod tests {
SonarrTaskName, SonarrTaskName,
}, },
}; };
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
SERIES_JSON, download_record, task,
};
#[test] #[test]
fn test_episode_display() { fn test_episode_display() {
@@ -698,6 +702,83 @@ mod tests {
); );
} }
#[test]
fn test_series_series_type_deserialization_falls_back_to_default() {
let expected = Series {
series_type: SeriesType::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!("notAThing")),
expected
);
}
#[test]
fn test_series_status_deserialization_falls_back_to_default() {
let expected = Series {
status: SeriesStatus::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_sonarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = SonarrTask {
task_name: SonarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType { fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType {
let history_item_json = json!({ let history_item_json = json!({
"id": 1, "id": 1,