test: drive a real Bot against a stand-in API

Every test went through `MockSender`, so `media_sender`'s `Bot`
implementation — the URL it builds, the multipart it sends, the per-chat
limiter and the bot-wide budget it charges — was never exercised, and neither
was any handler reached from a real update. The two things that made that hard
are gone:

- `media_sender::test_support::fake_api::FakeApi` is a stand-in for
  `api.telegram.org`: a `tokio` TCP listener that reads one HTTP/1.1 request
  (JSON or multipart), records it and answers the smallest result the method
  needs. No new dependency, and `Bot::new(token).set_api_url(api.url())`
  points a real `Bot` at it. Note for future tests: teloxide keys methods by
  payload type, so the path is `SendMediaGroup`, not `sendMediaGroup`.
- `message_handler` built its own `AppContext::from_statics` internally, so no
  test could reach its branches; its body is now `handle_message(ctx, bot,
  message)` with `message_handler` as the thin `dptree` entry.

Tests: a media group through the real `Bot` (asserting the multipart fields —
chat, media URL, caption — and that the send charged the chat's limiter), the
forward button through the real callback path (`CopyMessages`,
`DeleteMessage`, `AnswerCallbackQuery` with the prompt's ids and the toast
text), and `handle_message` twice (a prompt reply becoming an
`EditMessageCaption`, and a supported link in a group producing the one
explanatory `SendMessage`).

Also closes the redirect-hop gap left open by the download guard: the live
`a_redirect_into_the_hosts_network_is_refused` follows a public redirector to
`169.254.169.254` and asserts the policy refuses the hop (verified against
httpbin.org here, and by mutation — disabling the hop check fails it).

Docs: AGENTS.md's testing conventions and untested-modules list (the Bot
implementation and the handler branches are covered now; `main.rs`'s
startup/shutdown and its `dptree` tree still are not).

`cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (201 passed, 16 ignored) clean.
This commit is contained in:
2026-09-21 03:02:43 +08:00
parent cd8b5ac67b
commit 4e723e1657
7 changed files with 369 additions and 14 deletions
+44
View File
@@ -1396,6 +1396,50 @@ mod tests {
assert_eq!(sender.calls(), vec!["send_animation", "send_animation"]);
}
/// A media group through a **real** `Bot` — its request building, the
/// per-chat limiter, the bot-wide budget — against a stand-in API. The
/// scripted mock bypasses `media_sender`'s implementation entirely, so a
/// call site that stops charging the limiters (or a broken request shape)
/// is invisible to every other test.
#[tokio::test]
async fn a_media_group_reaches_the_api_through_a_real_bot() {
use crate::media_sender::test_support::fake_api::FakeApi;
use teloxide::Bot;
let api = FakeApi::start().await;
let bot = Bot::new("42:TEST").set_api_url(api.url());
let stores = TestStores::new();
let ctx = stores.ctx(&bot);
// A chat of its own: the limiter buckets are process-wide.
let mut task = sequence_task("https://cdn.example/1.jpg");
if let Task::SendMediaSequence { chat_id, .. } = &mut task {
*chat_id = 987_654;
}
let bucket = crate::rate_limit::limiter_for(987_654);
let before = bucket.tokens();
let outcome = send_media_sequence(&ctx, &task).await;
eprintln!(
"SCRATCH send methods={:?} outcome={outcome:?}",
api.methods()
);
assert!(outcome.is_ok());
// The request teloxide built: one group, the URL, the caption on the
// first item.
assert_eq!(api.methods(), vec!["SendMediaGroup"]);
let body = api.body("SendMediaGroup");
assert_eq!(body["chat_id"], 987_654);
assert_eq!(body["media"][0]["media"], "https://cdn.example/1.jpg");
assert_eq!(body["media"][0]["caption"], "cap");
// …and the send charged the pace limiter before it went out.
let after = bucket.tokens();
assert!(
after < before,
"a send must charge the chat's budget ({before} -> {after})"
);
}
#[tokio::test]
async fn forward_classifies_retry_after_and_permanent() {
use teloxide::types::Seconds;