You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-proto/tests/client_request_contract_tes...

1147 lines
41 KiB
Rust

use prost_reflect::Kind;
use synctv_proto::client::{
AddMediaRequest, ApproveRoomJoinReviewRequest, ClearPlaylistRequest, CreateRoomRequest,
CreateWebSocketTicketRequest, DeleteEntriesRequest, DeleteMediaRequest,
DeleteNotificationRequest, DeletePlaylistRequest, ExchangeAuthorizationCodeRequest,
GetAuthorizationUrlForBindRequest, GetAuthorizationUrlRequest, GetChatHistoryRequest,
GetNotificationRequest, GetRoomRequest, GetServerTimeRequest, ListPlaylistItemsRequest,
ListPlaylistsRequest, ListRoomJoinReviewsRequest, ListRoomLabelsRequest, MarkAsReadRequest,
MoveMediaRequest, MovePlaylistRequest, OAuth2ProviderInstancePathRequest,
OAuth2ProviderTypePathRequest, PasskeyAuthenticatorAssertionResponse,
RejectRoomJoinReviewRequest, RoomJoinReviewPathRequest, RoomMemberTargetPathRequest,
RoomPlaylistTargetPathRequest, RoomSettingsPatch, SortDirection, StartOpaqueLoginRequest,
StartPlaybackRequest, TransferRoomOwnershipRequest, UnlinkProviderRequest,
UpdatePlaybackStateRequest, UpdateRoomSettingsRequest, UploadUserAvatarObjectRequest,
WebSocketConnectRequest,
};
use synctv_proto::providers::common::{ListProviderBackendsRequest, ProviderInstanceQuery};
use synctv_proto::source_config::SourceProvider;
fn emby_target(item_id: &str) -> Option<synctv_proto::client::ProviderTarget> {
Some(synctv_proto::client::ProviderTarget {
target: Some(synctv_proto::client::provider_target::Target::Emby(
synctv_proto::client::EmbyTarget {
target: Some(synctv_proto::client::emby_target::Target::Item(
synctv_proto::client::EmbyItemTarget {
item_id: item_id.to_string(),
},
)),
},
)),
})
}
fn direct_url_media_source_config(
url: &str,
) -> Option<synctv_proto::source_config::MediaSourceConfig> {
Some(synctv_proto::source_config::MediaSourceConfig {
provider: Some(
synctv_proto::source_config::media_source_config::Provider::DirectUrl(
synctv_proto::source_config::DirectUrlMediaSourceConfig {
medias: vec![synctv_proto::source_config::DirectUrlMediaResourceConfig {
name: String::new(),
url: url.to_string(),
headers: Default::default(),
format: String::new(),
expires_at: None,
}],
default_media_index: None,
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
playback_kind: None,
duration_seconds: None,
proxy_mode: synctv_proto::source_config::PlaybackProxyMode::Auto as i32,
},
),
),
})
}
fn alist_playlist_source_config(
path: &str,
) -> Option<synctv_proto::source_config::PlaylistSourceConfig> {
Some(synctv_proto::source_config::PlaylistSourceConfig {
provider: Some(
synctv_proto::source_config::playlist_source_config::Provider::Alist(
synctv_proto::source_config::AlistPlaylistSourceConfig {
server_id: "alist-main".to_string(),
proxy_mode: synctv_proto::source_config::PlaybackProxyMode::Auto as i32,
path: path.to_string(),
password: None,
},
),
),
})
}
#[test]
fn playback_resources_share_the_explicit_p2p_delivery_contract() {
let pool = &synctv_proto::DESCRIPTOR_POOL;
let delivery = pool
.get_message_by_name("synctv.client.P2pResourceDelivery")
.expect("P2pResourceDelivery descriptor should exist");
for message_name in ["PlaybackMedia", "PlaybackSubtitle", "PlaybackDanmaku"] {
let message = pool
.get_message_by_name(&format!("synctv.client.{message_name}"))
.expect("playback resource descriptor should exist");
let field = message
.get_field_by_name("p2p_delivery")
.expect("playback resource should declare p2p_delivery");
let Kind::Message(field_message) = field.kind() else {
panic!("p2p_delivery should be a message field");
};
assert_eq!(field_message.full_name(), delivery.full_name());
}
}
#[test]
fn test_add_media_request_allows_room_root_without_playlist_id() {
let request = AddMediaRequest {
playlist_id: None,
provider_instance_name: String::new(),
source_config: direct_url_media_source_config("https://example.com/video.mp4"),
name: "Example".to_string(),
description: String::new(),
};
assert!(request.playlist_id.is_none());
}
#[test]
fn test_upload_object_content_type_is_optional() {
let message = synctv_proto::DESCRIPTOR_POOL
.get_message_by_name("synctv.client.UploadUserAvatarObjectRequest")
.expect("UploadUserAvatarObjectRequest descriptor should exist");
let content_type = message
.get_field_by_name("content_type")
.expect("content_type field should exist");
assert!(content_type.supports_presence());
let request: UploadUserAvatarObjectRequest = serde_json::from_str(
r#"{"encodedObjectKey":"object","token":"token","data":"cGF5bG9hZA=="}"#,
)
.expect("missing content_type should deserialize");
assert_eq!(request.content_type, None);
}
#[test]
fn two_factor_changes_use_the_verified_security_request() {
let preference_update = synctv_proto::DESCRIPTOR_POOL
.get_message_by_name("synctv.client.UpdateUserPreferencesRequest")
.expect("UpdateUserPreferencesRequest descriptor should exist");
assert!(
preference_update
.get_field_by_name("two_factor_enabled")
.is_none(),
"ordinary preference updates cannot change two-factor authentication"
);
let security_update = synctv_proto::DESCRIPTOR_POOL
.get_message_by_name("synctv.client.SetTwoFactorEnabledRequest")
.expect("SetTwoFactorEnabledRequest descriptor should exist");
assert!(security_update.get_field_by_name("enabled").is_some());
assert!(security_update
.get_field_by_name("verification_id")
.is_some());
}
#[test]
fn test_protojson_deserialization_uses_lower_camel_case_fields() {
let request: StartOpaqueLoginRequest =
serde_json::from_str(r#"{"loginSessionId":"login-session","credentialRequest":"AQID"}"#)
.expect("lowerCamelCase ProtoJSON request should deserialize");
assert_eq!(request.login_session_id, "login-session");
assert_eq!(request.credential_request.as_ref(), b"\x01\x02\x03");
let error = serde_json::from_str::<StartOpaqueLoginRequest>(
r#"{"loginSessionId":"login-session","credential_request":"AQID"}"#,
)
.expect_err("snake_case field names should be rejected");
assert!(error.to_string().contains("credential_request"));
}
#[test]
fn test_protojson_custom_json_name_rejects_proto_field_name() {
let response: PasskeyAuthenticatorAssertionResponse = serde_json::from_str(
r#"{"authenticatorData":"AQID","clientDataJSON":"BAUG","signature":"BwgJ"}"#,
)
.expect("custom json_name field should deserialize");
assert_eq!(response.authenticator_data.as_ref(), b"\x01\x02\x03");
assert_eq!(response.client_data_json.as_ref(), b"\x04\x05\x06");
assert_eq!(response.signature.as_ref(), b"\x07\x08\x09");
let error = serde_json::from_str::<PasskeyAuthenticatorAssertionResponse>(
r#"{"authenticatorData":"AQID","client_data_json":"BAUG","signature":"BwgJ"}"#,
)
.expect_err("proto snake_case field name should be rejected");
let message = error.to_string();
assert!(message.contains("client_data_json"), "{message}");
assert!(
!message.contains("expected one of: [\"authenticator_data\""),
"{message}"
);
assert!(!message.contains("\"client_data_json\""), "{message}");
}
#[test]
fn test_room_settings_patch_uses_lower_camel_case_fields() {
let patch: UpdateRoomSettingsRequest =
serde_json::from_str(
r#"{"settings":{"chatEnabled":false,"allowGuestJoin":true,"voiceChatEnabled":false,"p2pMediaEnabled":false},"updateMask":"chatEnabled,allowGuestJoin,voiceChatEnabled,p2pMediaEnabled"}"#,
)
.expect("lowerCamelCase room settings patch should deserialize");
let settings = patch.settings.expect("settings");
assert_eq!(settings.chat_enabled, Some(false));
assert_eq!(settings.allow_guest_join, Some(true));
assert_eq!(settings.voice_chat_enabled, Some(false));
assert_eq!(settings.p2p_media_enabled, Some(false));
let error = serde_json::from_str::<UpdateRoomSettingsRequest>(
r#"{"settings":{"chat_enabled":false,"allow_guest_join":true},"updateMask":"chatEnabled,allowGuestJoin"}"#,
)
.expect_err("snake_case room settings patch fields should be rejected");
assert!(error.to_string().contains("chat_enabled"));
}
#[test]
fn test_get_server_time_request_uses_lower_camel_case_query_field() {
let request: GetServerTimeRequest =
serde_urlencoded::from_str("clientSentAtNanos=1700000000123456789")
.expect("lowerCamelCase server time query should deserialize");
assert_eq!(request.client_sent_at_nanos, 1_700_000_000_123_456_789);
}
#[test]
fn test_room_settings_patch_rejects_duplicate_canonical_field() {
let error =
serde_json::from_str::<RoomSettingsPatch>(r#"{"chatEnabled":true,"chatEnabled":false}"#)
.expect_err("duplicate room settings field should be rejected");
assert!(error.to_string().contains("duplicate field"));
}
#[test]
fn test_update_room_settings_rejects_duplicate_canonical_field() {
let error = serde_json::from_str::<UpdateRoomSettingsRequest>(
r#"{"settings":{"chatEnabled":true},"settings":{"chatEnabled":false},"updateMask":"chatEnabled"}"#,
)
.expect_err("duplicate room settings field should be rejected");
assert!(error.to_string().contains("duplicate field"));
}
#[test]
fn test_protojson_deserialization_uses_integer_enums() {
let request: ListPlaylistsRequest = serde_json::from_str(
r#"{"page":1,"pageSize":20,"sourceProvider":3,"sortBy":1,"sortDirection":1}"#,
)
.expect("integer enum values should deserialize");
assert_eq!(request.source_provider, SourceProvider::Alist as i32);
assert_eq!(request.sort_direction, SortDirection::Asc as i32);
let error = serde_json::from_str::<ListPlaylistsRequest>(
r#"{"page":1,"pageSize":20,"sourceProvider":"SOURCE_PROVIDER_ALIST"}"#,
)
.expect_err("enum string values should be rejected");
assert!(error.is_data());
}
#[test]
fn test_protojson_query_deserialization_uses_lower_camel_case_and_integer_enums() {
let request: ListPlaylistsRequest =
serde_urlencoded::from_str("page=1&pageSize=20&sourceProvider=3&sortBy=1&sortDirection=1")
.expect("lowerCamelCase integer enum query should deserialize");
assert_eq!(request.page_size, 20);
assert_eq!(request.source_provider, SourceProvider::Alist as i32);
let field_error =
serde_urlencoded::from_str::<ListPlaylistsRequest>("page=1&page_size=20&sourceProvider=3")
.expect_err("snake_case query fields should be rejected");
assert!(field_error.to_string().contains("page_size"));
let enum_error = serde_urlencoded::from_str::<ListPlaylistsRequest>(
"page=1&pageSize=20&sourceProvider=SOURCE_PROVIDER_ALIST",
)
.expect_err("enum string query values should be rejected");
assert_ne!(enum_error.to_string(), "");
}
#[test]
fn test_error_message_code_is_application_int32() {
let error_message = synctv_proto::DESCRIPTOR_POOL
.get_message_by_name("synctv.client.ErrorMessage")
.expect("ErrorMessage descriptor should exist");
let code = error_message
.get_field_by_name("code")
.expect("ErrorMessage.code field should exist");
assert_eq!(code.kind(), Kind::Int32);
let message = synctv_proto::client::ErrorMessage {
message: "internal failure".to_string(),
code: 9000,
detail: String::new(),
client_operation_id: String::new(),
};
assert_eq!(message.code, 9000);
}
#[test]
fn test_add_media_request_requires_source_config() {
let request = AddMediaRequest {
playlist_id: None,
provider_instance_name: String::new(),
source_config: None,
name: "Example".to_string(),
description: String::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("source_config"), "{message}");
}
#[test]
fn test_add_media_request_rejects_invalid_playlist_id() {
let request = AddMediaRequest {
playlist_id: Some("bad-playlist".to_string()),
provider_instance_name: String::new(),
source_config: direct_url_media_source_config("https://example.com/video.mp4"),
name: "Example".to_string(),
description: String::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("playlist_id"), "{message}");
}
#[test]
fn test_list_playlist_items_request_allows_room_root_with_empty_playlist_id() {
let request = ListPlaylistItemsRequest {
playlist_id: String::new(),
target: None,
pagination: Some(
synctv_proto::client::list_playlist_items_request::Pagination::Page(
synctv_proto::client::PagePagination { page: 1 },
),
),
page_size: 50,
search: String::new(),
source_provider: SourceProvider::Unspecified as i32,
provider_instance_name: String::new(),
sort_by: synctv_proto::client::MediaListSortBy::Unspecified as i32,
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
refresh: false,
preview_source_config: None,
};
let json = serde_json::to_value(&request).expect("serialize list request");
assert!(json.get("playlistId").is_none());
assert!(json["target"].is_null());
let decoded: ListPlaylistItemsRequest =
serde_json::from_value(json.clone()).expect("deserialize list request");
assert!(decoded.target.is_none());
assert!(json.get("availability").is_none());
assert_eq!(
decoded.availability,
synctv_proto::client::ResourceAvailabilityFilter::All as i32
);
}
#[test]
fn test_get_room_request_rejects_invalid_room_id() {
let request = GetRoomRequest {
room_id: "bad-room".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("room_id"), "{message}");
}
#[test]
fn test_room_member_target_path_request_rejects_invalid_user_id() {
let request = RoomMemberTargetPathRequest {
room_id: "room_1".to_string(),
user_id: "bad-user".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("user_id"), "{message}");
}
#[test]
fn test_room_join_review_path_request_rejects_invalid_request_id() {
let request = RoomJoinReviewPathRequest {
room_id: "room_1".to_string(),
request_id: "bad-request".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("request_id"), "{message}");
}
#[test]
fn test_list_room_join_reviews_request_accepts_default_pagination_and_pending_status() {
let request = ListRoomJoinReviewsRequest {
page: 0,
page_size: 0,
status: synctv_proto::common::ReviewStatus::Pending as i32,
user_id: String::new(),
};
synctv_proto::validate(&request).expect("default pagination should be valid");
}
#[test]
fn test_list_room_join_reviews_request_rejects_invalid_page_size() {
let request = ListRoomJoinReviewsRequest {
page: 1,
page_size: 101,
status: synctv_proto::common::ReviewStatus::Pending as i32,
user_id: String::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("page_size"), "{message}");
}
#[test]
fn test_approve_room_join_review_request_requires_valid_request_id() {
let request = ApproveRoomJoinReviewRequest {
request_id: "bad-request".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("request_id"), "{message}");
}
#[test]
fn test_reject_room_join_review_request_allows_reason_without_target_user_id() {
let request = RejectRoomJoinReviewRequest {
request_id: "rev_AbC123xYz890".to_string(),
reason: "not eligible".to_string(),
};
synctv_proto::validate(&request).expect("request-id based rejection should be valid");
}
#[test]
fn test_room_playlist_target_path_request_rejects_invalid_playlist_id() {
let request = RoomPlaylistTargetPathRequest {
room_id: "room_1".to_string(),
playlist_id: "bad-playlist".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("playlist_id"), "{message}");
}
#[test]
fn test_delete_media_request_serializes_force_flag() {
let request = DeleteMediaRequest {
media_id: "media-1".to_string(),
force: true,
};
let json = serde_json::to_value(&request).expect("serialize delete media request");
assert_eq!(json["mediaId"], "media-1");
assert_eq!(json["force"], true);
}
#[test]
fn test_delete_playlist_request_serializes_force_flag() {
let request = DeletePlaylistRequest {
playlist_id: "playlist-1".to_string(),
force: true,
};
let json = serde_json::to_value(&request).expect("serialize delete playlist request");
assert_eq!(json["playlistId"], "playlist-1");
assert_eq!(json["force"], true);
}
#[test]
fn test_delete_entries_request_serializes_force_flag() {
let request = DeleteEntriesRequest {
playlist_ids: vec!["playlist-1".to_string()],
media_ids: vec!["media-1".to_string()],
force: true,
};
let json = serde_json::to_value(&request).expect("serialize delete entries request");
assert_eq!(json["playlistIds"][0], "playlist-1");
assert_eq!(json["mediaIds"][0], "media-1");
assert_eq!(json["force"], true);
}
#[test]
fn test_delete_entries_request_rejects_invalid_media_id() {
let request = DeleteEntriesRequest {
playlist_ids: vec!["pl_1".to_string()],
media_ids: vec!["bad-media".to_string()],
force: false,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("media_ids"), "{message}");
}
#[test]
fn test_clear_playlist_request_serializes_playlist_scope() {
let request = ClearPlaylistRequest {
playlist_id: "pl_1".to_string(),
};
let json = serde_json::to_value(&request).expect("serialize clear playlist request");
assert_eq!(json["playlistId"], "pl_1");
synctv_proto::validate(&request).expect("request should be valid");
}
#[test]
fn test_start_playback_request_serializes_dynamic_playlist_target() {
let request = StartPlaybackRequest {
media_id: String::new(),
playlist_id: "playlist-1".to_string(),
target: emby_target("provider-item-1"),
client_operation_id: None,
};
let json = serde_json::to_value(&request).expect("serialize start playback request");
assert!(json.get("mediaId").is_none());
assert_eq!(json["playlistId"], "playlist-1");
assert_eq!(
json["target"],
serde_json::to_value(emby_target("provider-item-1")).expect("target should serialize")
);
}
#[test]
fn test_start_playback_request_accepts_static_playlist_context() {
let request = StartPlaybackRequest {
media_id: "med_1".to_string(),
playlist_id: "pl_1".to_string(),
target: None,
client_operation_id: None,
};
synctv_proto::validate(&request).expect("static playlist context should be valid");
}
#[test]
fn test_create_playlist_request_serializes_dynamic_fields_without_is_folder() {
let request = synctv_proto::client::CreatePlaylistRequest {
name: "Dyn".to_string(),
parent_id: "playlist-root".to_string(),
source_provider: SourceProvider::Alist as i32,
source_config: alist_playlist_source_config("/tv"),
provider_instance_name: "alist-main".to_string(),
description: String::new(),
browse_access_mode: synctv_proto::client::PlaylistBrowseAccessMode::Default as i32,
};
let json = serde_json::to_value(&request).expect("serialize create playlist request");
assert!(json.get("is_folder").is_none());
assert_eq!(json["sourceProvider"], SourceProvider::Alist as i32);
assert_eq!(json["providerInstanceName"], "alist-main");
assert_eq!(
json["sourceConfig"],
serde_json::to_value(alist_playlist_source_config("/tv"))
.expect("source config should serialize")
);
}
#[test]
fn test_playlist_source_config_oneof_only_contains_dynamic_playlist_providers() {
let message = synctv_proto::DESCRIPTOR_POOL
.get_message_by_name("synctv.source_config.PlaylistSourceConfig")
.expect("PlaylistSourceConfig descriptor should exist");
let oneof = message
.oneofs()
.find(|oneof| oneof.name() == "provider")
.expect("PlaylistSourceConfig.provider oneof should exist");
let mut fields = oneof
.fields()
.map(|field| field.name().to_string())
.collect::<Vec<_>>();
fields.sort();
assert_eq!(
fields,
[
"alist",
"bilibili",
"cloudreve",
"douyin",
"emby",
"fnos",
"nextcloud",
"qnap",
"seafile",
"synology",
"tiktok",
"truenas",
"twitch",
"youtube"
]
);
}
#[test]
fn test_media_source_config_json_accepts_omitted_proto_default_fields() {
let config: synctv_proto::source_config::MediaSourceConfig = serde_json::from_str(
r#"{"directUrl":{"medias":[{"url":"https://example.com/video.mp4"}]}}"#,
)
.expect("directUrl source_config should allow omitted default fields");
let provider = config
.provider
.expect("direct_url provider should be retained");
match provider {
synctv_proto::source_config::media_source_config::Provider::DirectUrl(config) => {
assert_eq!(config.medias.len(), 1);
assert_eq!(config.medias[0].url, "https://example.com/video.mp4");
assert_eq!(config.subtitles.len(), 0);
assert_eq!(config.danmakus.len(), 0);
}
other => panic!("unexpected provider: {other:?}"),
}
}
#[test]
fn test_media_source_config_json_ignores_unknown_provider_key() {
let config = serde_json::from_str::<synctv_proto::source_config::MediaSourceConfig>(
r#"{"unknown_provider":{"url":"https://example.com/video.mp4"}}"#,
)
.expect("unknown provider key should be ignored");
assert!(config.provider.is_none());
}
#[test]
fn test_media_source_config_json_ignores_unknown_fields() {
let config = serde_json::from_str::<synctv_proto::source_config::MediaSourceConfig>(
r#"{
"directUrl": {
"preferProxy": true,
"proxyOnly": true,
"futureField": {"version": 2},
"medias": [{
"url": "https://example.com/video.mp4",
"legacyHeaders": {"X-Legacy": "ignored"}
}]
}
}"#,
)
.expect("source config should ignore unknown fields");
let Some(synctv_proto::source_config::media_source_config::Provider::DirectUrl(config)) =
config.provider
else {
panic!("expected direct URL provider");
};
assert_eq!(config.proxy_mode, 0);
assert_eq!(config.medias.len(), 1);
assert_eq!(config.medias[0].url, "https://example.com/video.mp4");
}
#[test]
fn test_playlist_source_config_json_ignores_unknown_fields() {
let config = serde_json::from_str::<synctv_proto::source_config::PlaylistSourceConfig>(
r#"{
"alist": {
"serverId": "alist-main",
"path": "/shows",
"futureField": "ignored"
}
}"#,
)
.expect("playlist source config should ignore unknown fields");
let Some(synctv_proto::source_config::playlist_source_config::Provider::Alist(config)) =
config.provider
else {
panic!("expected Alist playlist provider");
};
assert_eq!(config.server_id, "alist-main");
assert_eq!(config.path, "/shows");
}
#[test]
fn test_media_source_config_json_rejects_multiple_provider_keys() {
let error = serde_json::from_str::<synctv_proto::source_config::MediaSourceConfig>(
r#"{"directUrl":{"medias":[{"url":"https://example.com/video.mp4"}]},"rtmp":{}}"#,
)
.expect_err("media source_config should accept exactly one provider");
assert!(error.to_string().contains("duplicate field"), "{error}");
}
#[test]
fn test_bilibili_source_config_json_accepts_single_source_key() {
let config: synctv_proto::source_config::MediaSourceConfig =
serde_json::from_str(r#"{"bilibili":{"video":{"bvid":"BV1234567890","cid":"42"}}}"#)
.expect("bilibili source_config should deserialize");
let provider = config
.provider
.expect("bilibili provider should be retained");
match provider {
synctv_proto::source_config::media_source_config::Provider::Bilibili(config) => {
assert!(matches!(
config.source,
Some(synctv_proto::source_config::bilibili_media_source_config::Source::Video(_))
));
}
other => panic!("unexpected provider: {other:?}"),
}
}
#[test]
fn test_provider_instance_query_allows_empty_instance_name() {
let request = ProviderInstanceQuery {
instance_name: String::new(),
};
synctv_proto::validate(&request).expect("empty instance name should be allowed");
}
#[test]
fn test_provider_instance_query_rejects_invalid_instance_name() {
let request = ProviderInstanceQuery {
instance_name: "bad name".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("instance_name"), "{message}");
}
#[test]
fn test_list_provider_backends_request_rejects_invalid_provider_type_format() {
let request = ListProviderBackendsRequest { provider_type: 99 };
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider_type"), "{message}");
}
#[test]
fn test_get_authorization_url_request_rejects_invalid_provider_instance_name() {
let request = GetAuthorizationUrlRequest {
provider: "bad provider".to_string(),
redirect_url: Some(String::new()),
native: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider"), "{message}");
}
#[test]
fn test_oauth2_provider_instance_path_request_rejects_invalid_provider_name() {
let request = OAuth2ProviderInstancePathRequest {
provider: "bad provider".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider"), "{message}");
}
#[test]
fn test_get_authorization_url_for_bind_request_rejects_dangerous_redirect_url() {
let request = GetAuthorizationUrlForBindRequest {
provider: "github".to_string(),
redirect_url: Some("javascript:alert(1)".to_string()),
verification_id: "verification-id".to_string(),
native: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("redirect_url"), "{message}");
}
#[test]
fn test_get_authorization_url_request_rejects_non_loopback_http_redirect_url() {
let request = GetAuthorizationUrlRequest {
provider: "github".to_string(),
redirect_url: Some("http://example.com/oauth2/callback".to_string()),
native: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("redirect_url"), "{message}");
}
#[test]
fn test_exchange_authorization_code_request_rejects_invalid_state() {
let request = ExchangeAuthorizationCodeRequest {
code: "code.with.dots".to_string(),
state: "short".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("state"), "{message}");
}
#[test]
fn test_unlink_provider_request_rejects_invalid_provider_type() {
let request = UnlinkProviderRequest {
provider: synctv_proto::client::OAuth2ProviderType::Oauth2ProviderTypeUnspecified as i32,
provider_user_id: String::new(),
provider_instance_name: String::new(),
verification_id: "verification-id".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider"), "{message}");
}
#[test]
fn test_unlink_provider_request_requires_instance_for_specific_identity() {
let request = UnlinkProviderRequest {
provider: synctv_proto::client::OAuth2ProviderType::Oauth2ProviderTypeGithub as i32,
provider_user_id: "remote-user-1".to_string(),
provider_instance_name: String::new(),
verification_id: "verification-id".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(
message.contains("oauth2.unlink_provider.instance_for_specific_identity"),
"{message}"
);
}
#[test]
fn test_oauth2_provider_type_path_request_rejects_invalid_provider_type() {
let request = OAuth2ProviderTypePathRequest {
provider: "github-main".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider"), "{message}");
}
#[test]
fn test_oauth2_provider_type_path_request_accepts_apple() {
let request = OAuth2ProviderTypePathRequest {
provider: "apple".to_string(),
};
synctv_proto::validate(&request).expect("Apple provider path should be valid");
}
#[test]
fn test_discover_rooms_request_rejects_too_long_search() {
let request = synctv_proto::client::DiscoverRoomsRequest {
page: 1,
page_size: 20,
search: "a".repeat(101),
category_id: String::new(),
label_ids: Vec::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("search"), "{message}");
}
#[test]
fn test_create_room_request_defaults_optional_taxonomy_fields_from_json() {
let request: CreateRoomRequest = serde_json::from_str(r#"{"name":"room","settings":{}}"#)
.expect("request should deserialize");
assert_eq!(request.category_id, "");
assert_eq!(request.label_ids.len(), 0);
}
#[test]
fn test_discover_rooms_request_defaults_taxonomy_filters_from_json() {
let request: synctv_proto::client::DiscoverRoomsRequest =
serde_json::from_str("{}").expect("request should deserialize");
assert_eq!(request.category_id, "");
assert_eq!(request.label_ids.len(), 0);
}
#[test]
fn test_list_room_labels_request_defaults_category_filter_from_json() {
let request: ListRoomLabelsRequest =
serde_json::from_str("{}").expect("request should deserialize");
assert!(!request.include_disabled);
assert_eq!(request.category_id, "");
}
#[test]
fn test_create_playlist_request_rejects_unknown_source_provider_json() {
let error = serde_json::from_str::<synctv_proto::client::CreatePlaylistRequest>(
r#"{"name":"Dyn","sourceProvider":999,"sourceConfig":{"alist":{"serverId":"alist-main","path":"/tv"}},"providerInstanceName":"alist-main"}"#,
)
.expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("999"), "{message}");
}
#[test]
fn test_list_playlists_request_rejects_invalid_provider_filters() {
let request = ListPlaylistsRequest {
parent_id: String::new(),
page: 1,
page_size: 20,
search: String::new(),
source_provider: 99,
provider_instance_name: "bad name".to_string(),
dynamic_only: None,
sort_by: synctv_proto::client::PlaylistListSortBy::Unspecified as i32,
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(
message.contains("source_provider") || message.contains("provider_instance_name"),
"{message}"
);
}
#[test]
fn test_list_playlist_items_request_rejects_invalid_provider_filters() {
let request = ListPlaylistItemsRequest {
playlist_id: String::new(),
target: None,
pagination: Some(
synctv_proto::client::list_playlist_items_request::Pagination::Page(
synctv_proto::client::PagePagination { page: 1 },
),
),
page_size: 20,
search: String::new(),
source_provider: 99,
provider_instance_name: "bad name".to_string(),
sort_by: synctv_proto::client::MediaListSortBy::Unspecified as i32,
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
refresh: false,
preview_source_config: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(
message.contains("source_provider") || message.contains("provider_instance_name"),
"{message}"
);
}
#[test]
fn test_add_media_request_rejects_invalid_provider_identifiers() {
let request = AddMediaRequest {
playlist_id: None,
provider_instance_name: "bad name".to_string(),
source_config: direct_url_media_source_config("https://example.com/video.mp4"),
name: "Example".to_string(),
description: String::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("provider_instance_name"), "{message}");
}
#[test]
fn test_transfer_room_ownership_request_rejects_invalid_new_owner_user_id() {
let request = TransferRoomOwnershipRequest {
new_owner_user_id: "bad-id".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("new_owner_user_id"), "{message}");
}
#[test]
fn test_get_chat_history_request_rejects_invalid_limit() {
let request = GetChatHistoryRequest {
limit: 101,
cursor: String::new(),
include_message_types: Vec::new(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("limit"), "{message}");
}
#[test]
fn test_move_playlist_request_rejects_invalid_anchor_playlist_id() {
let request = MovePlaylistRequest {
playlist_id: "pl_1".to_string(),
anchor: Some(
synctv_proto::client::move_playlist_request::Anchor::BeforePlaylistId(
"bad-playlist".to_string(),
),
),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("before_playlist_id"), "{message}");
}
#[test]
fn test_move_media_request_rejects_invalid_anchor_media_id() {
let request = MoveMediaRequest {
media_ids: vec!["med_1".to_string()],
source_playlist_id: None,
target_playlist_id: None,
all_from_scope: false,
before_media_id: Some("bad-media".to_string()),
after_media_id: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("before_media_id"), "{message}");
}
#[test]
fn test_update_playback_state_rejects_missing_type() {
let request = UpdatePlaybackStateRequest {
r#type: synctv_proto::client::PlaybackUpdateType::Unspecified as i32,
playing: None,
position: Some(1.0),
speed: None,
version: None,
expected_media_id: Some(String::new()),
expected_playlist_id: Some(String::new()),
expected_target_hash: Some(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
),
client_operation_id: None,
client_time_millis: None,
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(
message.contains("update_playback_state.type_required"),
"{message}"
);
}
#[test]
fn test_update_playback_state_allows_full_seek_state() {
let request = UpdatePlaybackStateRequest {
r#type: synctv_proto::client::PlaybackUpdateType::Seek as i32,
playing: Some(false),
position: Some(42.5),
speed: Some(1.25),
version: Some(7),
expected_media_id: Some(String::new()),
expected_playlist_id: Some(String::new()),
expected_target_hash: Some(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
),
client_operation_id: None,
client_time_millis: None,
};
synctv_proto::validate(&request).expect("request should be valid");
}
#[test]
fn test_create_websocket_ticket_request_rejects_invalid_room_id() {
let request = CreateWebSocketTicketRequest {
room_id: "bad-room".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("room_id"), "{message}");
}
#[test]
fn test_websocket_connect_request_rejects_invalid_ticket_format() {
let request = WebSocketConnectRequest {
ticket: "bad ticket".to_string(),
};
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("ticket"), "{message}");
}
#[test]
fn test_websocket_connect_request_allows_empty_ticket_for_header_auth() {
let request = WebSocketConnectRequest {
ticket: String::new(),
};
synctv_proto::validate(&request).expect("empty ticket should be allowed for header auth");
}
#[test]
fn test_get_notification_request_accepts_numeric_notification_id() {
let request = GetNotificationRequest {
notification_id: 42,
};
synctv_proto::validate(&request).expect("numeric notification ID should be valid");
}
#[test]
fn test_mark_as_read_request_accepts_numeric_notification_ids() {
let request = MarkAsReadRequest {
notification_ids: vec![42, 43],
};
synctv_proto::validate(&request).expect("numeric notification IDs should be valid");
}
#[test]
fn test_delete_notification_request_accepts_numeric_notification_id() {
let request = DeleteNotificationRequest {
notification_id: 42,
};
synctv_proto::validate(&request).expect("numeric notification ID should be valid");
}
#[test]
fn test_delete_notification_request_rejects_invalid_notification_id() {
let request = DeleteNotificationRequest { notification_id: 0 };
let error = synctv_proto::validate(&request).expect_err("request should be invalid");
let message = error.to_string();
assert!(message.contains("notification_id"), "{message}");
}