mod mock;
use rustigram_api::error::Error;
use rustigram_api::{BotClient, ClientConfig};
use serde_json::json;
use wiremock::MockServer;
fn message() -> serde_json::Value {
json!({ "message_id": 1, "date": 1_700_000_000, "chat": { "id": 42, "type": "private" } })
}
fn client_with_retries(server: &MockServer, max_retries: u8) -> BotClient {
let config = ClientConfig::new(mock::TOKEN)
.expect("the test token is well-formed")
.api_base_url(server.uri())
.max_retries(max_retries);
BotClient::new(config).expect("client builds")
}
#[tokio::test]
async fn a_successful_response_decodes_into_its_result_type() {
let (server, client) = mock::spawn().await;
mock::mount_ok(&server, "sendMessage", message()).await;
let sent = client
.send_message(42_i64, "hello")
.await
.expect("a well-formed success decodes");
assert_eq!(sent.message_id, 1);
assert_eq!(sent.chat.id, 42);
}
#[tokio::test]
async fn a_success_with_no_result_is_an_error_not_a_panic() {
let (server, client) = mock::spawn().await;
mock::mount_raw(&server, "sendMessage", 200, r#"{"ok":true,"result":null}"#).await;
let error = client
.send_message(42_i64, "hello")
.await
.expect_err("a null result cannot produce a Message");
assert!(
matches!(error, Error::Decode(_)),
"expected a decode error, got {error:?}"
);
}
#[tokio::test]
async fn an_api_error_keeps_its_code_and_description() {
let (server, client) = mock::spawn().await;
mock::mount_api_error(
&server,
"sendMessage",
400,
"Bad Request: chat not found",
None,
)
.await;
let error = client
.send_message(42_i64, "hello")
.await
.expect_err("ok: false is an error");
match error {
Error::Api {
error_code,
description,
..
} => {
assert_eq!(error_code, 400);
assert_eq!(description, "Bad Request: chat not found");
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn a_migration_error_carries_the_new_chat_id() {
let (server, client) = mock::spawn().await;
mock::mount_api_error(
&server,
"sendMessage",
400,
"Bad Request: group chat was upgraded to a supergroup chat",
Some(json!({ "migrate_to_chat_id": -1_001_234_567_890_i64 })),
)
.await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert_eq!(
error_migrate_id(&error),
Some(-1_001_234_567_890),
"the new chat id was dropped, so a caller cannot follow the migration"
);
}
fn error_migrate_id(error: &Error) -> Option<i64> {
match error {
Error::Api {
migrate_to_chat_id, ..
} => *migrate_to_chat_id,
_ => None,
}
}
#[tokio::test]
async fn a_malformed_body_maps_to_a_decode_error() {
let (server, client) = mock::spawn().await;
mock::mount_raw(&server, "sendMessage", 200, "<html>502 Bad Gateway</html>").await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert!(
matches!(error, Error::Decode(_)),
"an unparseable body must be a decode error, got {error:?}"
);
}
#[tokio::test]
async fn a_flood_control_error_is_retried_and_then_succeeds() {
let (server, client) = mock::spawn().await;
mock::mount_then(
&server,
"sendMessage",
mock::flood_control(0),
1,
json!({ "ok": true, "result": message() }),
)
.await;
let sent = client
.send_message(42_i64, "hello")
.await
.expect("the retry succeeds");
assert_eq!(sent.message_id, 1);
assert_eq!(
mock::requests(&server).await.len(),
2,
"the call should have been sent twice: once rate-limited, once accepted"
);
}
#[tokio::test]
async fn the_retry_budget_is_spent_exactly_and_then_surfaces() {
for budget in [0_u8, 1, 3] {
let server = MockServer::start().await;
let client = client_with_retries(&server, budget);
mock::mount_api_error(
&server,
"sendMessage",
429,
"Too Many Requests: retry after 0",
Some(json!({ "retry_after": 0 })),
)
.await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert!(
matches!(error, Error::RateLimit { .. }),
"an exhausted budget must surface as RateLimit, got {error:?}"
);
assert_eq!(
mock::requests(&server).await.len(),
usize::from(budget) + 1,
"with max_retries({budget}) the call should be sent {} time(s)",
budget + 1
);
}
}
#[tokio::test]
async fn the_requested_wait_reaches_the_caller() {
let server = MockServer::start().await;
let client = client_with_retries(&server, 0);
mock::mount_api_error(
&server,
"sendMessage",
429,
"Too Many Requests: retry after 42",
Some(json!({ "retry_after": 42 })),
)
.await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert_eq!(
error.retry_after(),
Some(42),
"the caller cannot back off correctly without the wait Telegram named"
);
}
#[tokio::test]
async fn a_byte_upload_is_not_retried_on_flood_control() {
let server = MockServer::start().await;
let client = client_with_retries(&server, 5);
mock::mount_api_error(
&server,
"sendPhoto",
429,
"Too Many Requests: retry after 0",
Some(json!({ "retry_after": 0 })),
)
.await;
let error = client
.send_photo(42_i64, mock::fixtures::uploaded_file())
.await
.unwrap_err();
assert!(matches!(error, Error::RateLimit { .. }), "got {error:?}");
assert_eq!(
mock::requests(&server).await.len(),
1,
"the multipart path does not retry, so exactly one request should have \
been sent despite max_retries(5)"
);
}
#[tokio::test]
async fn the_error_predicates_match_real_payloads() {
for (code, description, check, name) in [
(
403_u16,
"Forbidden: bot was blocked by the user",
Error::is_blocked as fn(&Error) -> bool,
"is_blocked",
),
(
400,
"Bad Request: chat not found",
Error::is_chat_not_found,
"is_chat_not_found",
),
(
429,
"Too Many Requests: retry after 5",
Error::is_rate_limit,
"is_rate_limit",
),
] {
let server = MockServer::start().await;
let client = client_with_retries(&server, 0);
mock::mount_api_error(&server, "sendMessage", code, description, None).await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert!(
check(&error),
"`{name}` did not match the payload Telegram sends for it: \
{code} {description:?} produced {error:?}"
);
}
}
#[tokio::test]
async fn the_error_predicates_reject_unrelated_errors() {
let server = MockServer::start().await;
let client = client_with_retries(&server, 0);
mock::mount_api_error(
&server,
"sendMessage",
400,
"Bad Request: message is too long",
None,
)
.await;
let error = client.send_message(42_i64, "hi").await.unwrap_err();
assert!(!error.is_blocked(), "is_blocked matched an unrelated 400");
assert!(
!error.is_chat_not_found(),
"is_chat_not_found matched an unrelated 400"
);
assert!(
!error.is_rate_limit(),
"is_rate_limit matched an unrelated 400"
);
assert_eq!(error.retry_after(), None, "an unrelated 400 named a wait");
}
#[tokio::test]
async fn download_file_fetches_the_bytes_from_the_file_endpoint() {
let (server, client) = mock::spawn().await;
mock::mount_file(&server, "photos/file_1.jpg", b"\xff\xd8\xffbytes").await;
let bytes = client
.download_file("photos/file_1.jpg")
.await
.expect("the download succeeds");
assert_eq!(&bytes[..], b"\xff\xd8\xffbytes");
let request = mock::only_request(&server).await;
assert_eq!(
request.url.path(),
format!("/file/bot{}/photos/file_1.jpg", mock::TOKEN),
"the download URL must include the token and the file path verbatim"
);
}
#[tokio::test]
async fn an_expired_file_path_is_an_error_not_the_error_page() {
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, ResponseTemplate};
let (server, client) = mock::spawn().await;
Mock::given(method("GET"))
.and(path_regex(".*"))
.respond_with(ResponseTemplate::new(404).set_body_string(
r#"{"ok":false,"error_code":404,"description":"Not Found: file is temporarily unavailable"}"#,
))
.mount(&server)
.await;
let error = client
.download_file("photos/expired.jpg")
.await
.expect_err("a 404 from the file endpoint is a failure");
match error {
Error::Api {
error_code,
description,
..
} => {
assert_eq!(error_code, 404);
assert!(
description.contains("temporarily unavailable"),
"the reason Telegram gave was lost: {description}"
);
}
other => panic!("expected Error::Api carrying Telegram's reason, got {other:?}"),
}
}
#[tokio::test]
async fn a_download_failure_without_an_envelope_still_errors() {
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, ResponseTemplate};
let (server, client) = mock::spawn().await;
Mock::given(method("GET"))
.and(path_regex(".*"))
.respond_with(ResponseTemplate::new(502).set_body_string("<html>502 Bad Gateway</html>"))
.mount(&server)
.await;
let error = client.download_file("photos/x.jpg").await.unwrap_err();
match error {
Error::Api { error_code, .. } => assert_eq!(
error_code, 502,
"with no envelope the HTTP status is what the caller gets"
),
other => panic!("expected Error::Api, got {other:?}"),
}
}