mod mock;
use mock::fixtures;
use rustigram_api::BotClient;
use serde_json::Value;
use std::collections::BTreeSet;
use wiremock::Request;
fn field_names(request: &Request) -> BTreeSet<String> {
match serde_json::from_slice::<Value>(&request.body) {
Ok(Value::Object(body)) => body.keys().cloned().collect(),
_ => mock::multipart_field_names(request).into_iter().collect(),
}
}
macro_rules! both_paths {
(|$client:ident, $file:ident| $call:expr) => {{
async fn run(upload: bool) -> BTreeSet<String> {
let (server, $client) = mock::spawn().await;
mock::mount_catch_all(&server).await;
let $file = if upload {
fixtures::uploaded_file()
} else {
fixtures::input_file()
};
let _ = $call.await;
field_names(&mock::only_request(&server).await)
}
(run(true).await, run(false).await)
}};
}
fn difference(
method: &str,
multipart: &BTreeSet<String>,
json: &BTreeSet<String>,
) -> Option<String> {
let dropped: Vec<&String> = json.difference(multipart).collect();
let extra: Vec<&String> = multipart.difference(json).collect();
(!dropped.is_empty() || !extra.is_empty()).then(|| {
format!(
" {method}:\n never reaches Telegram on a byte upload: {dropped:?}\n never reaches Telegram by file_id: {extra:?}"
)
})
}
#[tokio::test]
async fn media_builders_send_the_same_options_on_both_paths() {
let mut differences = Vec::new();
let (multipart, json) = both_paths!(|c, f| c
.send_photo(1_i64, f)
.caption("cap")
.protect_content(true)
.message_effect_id("effect")
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7)));
differences.extend(difference("sendPhoto", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_audio(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.caption("cap")
.parse_mode(rustigram_types::message::ParseMode::HTML)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendAudio", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_document(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.caption("cap")
.parse_mode(rustigram_types::message::ParseMode::HTML)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendDocument", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_video(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.caption("cap")
.parse_mode(rustigram_types::message::ParseMode::HTML)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendVideo", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_animation(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.caption("cap")
.parse_mode(rustigram_types::message::ParseMode::HTML)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendAnimation", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_voice(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.caption("cap")
.parse_mode(rustigram_types::message::ParseMode::HTML)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendVoice", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_video_note(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendVideoNote", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_sticker(1_i64, f)
.business_connection_id("bc")
.message_thread_id(3)
.direct_messages_topic_id(4)
.disable_notification(true)
.message_effect_id("effect")
.protect_content(true)
.allow_paid_broadcast(true)
.reply_parameters(fixtures::reply_to(7))
.receiver_user_id(9)
.callback_query_id("cq"));
differences.extend(difference("sendSticker", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c
.send_live_photo(1_i64, f, fixtures::input_file())
.caption("cap")
.has_spoiler(true)
.show_caption_above_media(true)
.protect_content(true)
.message_effect_id("effect")
.reply_parameters(fixtures::reply_to(7)));
differences.extend(difference("sendLivePhoto", &multipart, &json));
let (multipart, json) = both_paths!(|c, f| c.set_chat_photo(1_i64, f));
differences.extend(difference("setChatPhoto", &multipart, &json));
assert!(
differences.is_empty(),
"{} media builder(s) send different options depending on how the file \
travels. An option accepted by the builder and absent from one path is \
silently lost — the call still succeeds:\n{}",
differences.len(),
differences.join("\n")
);
}
#[tokio::test]
async fn a_reply_survives_a_byte_upload() {
let (server, client) = mock::spawn().await;
mock::mount_catch_all(&server).await;
let _ = client
.send_photo(42_i64, fixtures::uploaded_file())
.reply_parameters(fixtures::reply_to(7))
.await;
let request = mock::only_request(&server).await;
let fields = mock::multipart_field_names(&request);
assert!(
fields.iter().any(|f| f == "reply_parameters"),
"the reply was dropped from the multipart form; Telegram would send the \
photo as a new message instead of a reply. Fields sent: {fields:?}"
);
}
#[tokio::test]
async fn the_shared_multipart_options_all_reach_the_form() {
let (server, client) = mock::spawn().await;
mock::mount_catch_all(&server).await;
let _ = client
.send_photo(42_i64, fixtures::uploaded_file())
.reply_parameters(fixtures::reply_to(7))
.message_effect_id("effect")
.allow_paid_broadcast(true)
.caption("cap")
.protect_content(true)
.await;
let fields = mock::multipart_field_names(&mock::only_request(&server).await);
for option in [
"reply_parameters",
"message_effect_id",
"allow_paid_broadcast",
"caption",
"protect_content",
] {
assert!(
fields.iter().any(|f| f == option),
"`{option}` was set on the builder and never reached the form. \
Fields sent: {fields:?}"
);
}
}
#[tokio::test]
async fn the_two_paths_use_the_encodings_they_are_named_for() {
async fn content_type(file: rustigram_types::file::InputFile) -> String {
let (server, client) = mock::spawn().await;
mock::mount_catch_all(&server).await;
let _ = client.send_photo(1_i64, file).await;
mock::only_request(&server)
.await
.headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned()
}
let upload = content_type(fixtures::uploaded_file()).await;
assert!(
upload.starts_with("multipart/form-data"),
"a byte upload must go out as multipart, got `{upload}`"
);
let by_id = content_type(fixtures::input_file()).await;
assert!(
by_id.starts_with("application/json"),
"a file_id send must go out as JSON, got `{by_id}`"
);
}
#[allow(dead_code)]
fn fixtures_match_the_builder_signatures(client: &BotClient) {
let _ = client.send_photo(1_i64, fixtures::uploaded_file());
let _ = client.send_photo(1_i64, fixtures::input_file());
}
#[test]
fn every_media_builder_exposes_exactly_the_options_its_method_takes() {
let source = include_str!("../src/methods/sending.rs");
let spec: SpecMethods = serde_json::from_str(SNAPSHOT).expect("the snapshot parses");
let mut wrong = Vec::new();
for (builder, api_method, exposed) in media_builders(source) {
let Some(params) = spec.methods.get(&api_method) else {
wrong.push(format!(" {builder}: `{api_method}` is not in the spec"));
continue;
};
for option in &exposed {
if !params.contains_key(option.as_str()) {
wrong.push(format!(
" {api_method}: exposes `{option}`, which the spec does not \
define for it — a caller can set it and Telegram ignores it"
));
}
}
for option in SHARED_OPTIONS {
if params.contains_key(*option) && !exposed.iter().any(|e| e == option) {
wrong.push(format!(
" {api_method}: the spec takes `{option}` and no setter reaches it"
));
}
}
}
assert!(
wrong.is_empty(),
"{} media builder surface mismatch(es) against the Bot API spec:\n{}",
wrong.len(),
wrong.join("\n")
);
}
const SHARED_OPTIONS: &[&str] = &[
"business_connection_id",
"message_thread_id",
"direct_messages_topic_id",
"caption",
"parse_mode",
"caption_entities",
"show_caption_above_media",
"has_spoiler",
"disable_notification",
"protect_content",
"allow_paid_broadcast",
"message_effect_id",
"reply_parameters",
"reply_markup",
"suggested_post_parameters",
"receiver_user_id",
"callback_query_id",
];
const SNAPSHOT: &str = include_str!("../../rustigram-types/tests/spec/bot-api-10.2.json");
#[derive(serde::Deserialize)]
struct SpecMethods {
methods:
std::collections::BTreeMap<String, std::collections::BTreeMap<String, serde_json::Value>>,
}
fn media_builders(source: &str) -> Vec<(String, String, Vec<String>)> {
const MACRO_UNIVERSAL: &[&str] = &[
"business_connection_id",
"message_thread_id",
"direct_messages_topic_id",
"disable_notification",
"message_effect_id",
"protect_content",
"allow_paid_broadcast",
"reply_parameters",
"reply_markup",
"suggested_post_parameters",
"receiver_user_id",
"callback_query_id",
];
let mut found = Vec::new();
for block in source.split("media_sender!(").skip(1) {
let head = block.split(");").next().unwrap_or_default();
let Some(builder) = head
.split(|c: char| !c.is_alphanumeric() && c != '_')
.find(|token| token.starts_with("Send"))
.map(str::to_owned)
else {
continue;
};
let quoted: Vec<&str> = head.split('"').skip(1).step_by(2).collect();
let Some(api_method) = quoted.get(1) else {
continue;
};
let caption_opts: Vec<String> = head
.rsplit_once('[')
.and_then(|(_, tail)| tail.split(']').next())
.map(|list| {
list.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
let exposed = MACRO_UNIVERSAL
.iter()
.map(|s| (*s).to_owned())
.chain(caption_opts)
.collect();
found.push((builder, (*api_method).to_owned(), exposed));
}
let generated = found.len();
assert_eq!(
generated, 7,
"expected seven macro-generated builders, parsed {generated}"
);
for block in source.split("\npub struct ").skip(1) {
let builder = block
.split_whitespace()
.next()
.unwrap_or_default()
.to_owned();
let Some(body) = block.split("\n}").next() else {
continue;
};
if !body.contains("opts: MediaSendOptions") || !builder.starts_with("Send") {
continue;
}
if found.iter().any(|(name, _, _)| *name == builder) {
continue;
}
let Some(start) = source.find(&format!("impl {builder} {{")) else {
continue;
};
let impl_block = &source[start
..source[start + 5..]
.find("\nimpl ")
.map_or(source.len(), |o| start + 5 + o)];
let mut exposed = Vec::new();
let mut rest = impl_block;
while let Some(at) = rest.find("pub fn ") {
rest = &rest[at + 7..];
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let after = &rest[name.len()..];
if after.trim_start().starts_with('(')
&& after[..after.find(')').unwrap_or(0).max(1)].contains("mut self")
{
exposed.push(name);
}
}
let api_method = source
.find(&format!("impl IntoFuture for {builder} "))
.and_then(|at| {
let tail = &source[at..];
["post_multipart(\"", "post_json(\""]
.iter()
.filter_map(|marker| tail.find(marker).map(|o| (o, *marker)))
.min_by_key(|(o, _)| *o)
.and_then(|(o, marker)| {
tail[o + marker.len()..]
.split('"')
.next()
.map(str::to_owned)
})
})
.unwrap_or_default();
found.push((builder, api_method, exposed));
}
assert!(
found.len() > generated,
"no hand-written media builders were found — they were invisible to this \
test once already, and both had spec gaps"
);
found
}
#[test]
fn no_media_builder_shadows_a_shared_option() {
let source = include_str!("../src/methods/sending.rs");
let shared: Vec<&str> = source
.split("pub struct MediaSendOptions {")
.nth(1)
.and_then(|s| s.split("\n}").next())
.expect("the MediaSendOptions declaration")
.lines()
.filter_map(|l| l.trim().strip_prefix("pub "))
.filter_map(|l| l.split(':').next())
.collect();
assert!(
shared.len() > 10,
"parsed only {} shared options — the struct's shape changed and this \
test would check almost nothing",
shared.len()
);
let mut shadowed = Vec::new();
for block in source.split("\npub struct ").skip(1) {
let name = block.split_whitespace().next().unwrap_or_default();
let Some(body) = block.split("\n}").next() else {
continue;
};
if !body.contains("opts: MediaSendOptions") {
continue;
}
for line in body.lines() {
let Some((field, _)) = line.trim().trim_start_matches("pub ").split_once(':') else {
continue;
};
let field = field.trim();
if shared.contains(&field) {
shadowed.push(format!(" {name}.{field} shadows MediaSendOptions.{field}"));
}
}
}
assert!(
shadowed.is_empty(),
"{} builder field(s) duplicate a shared option. The shared encoders read \
`opts` only, so a local copy reaches the wire only where some send path \
remembers to add it by hand — route it through `self.opts` \
instead:\n{}",
shadowed.len(),
shadowed.join("\n")
);
}