use super::*;
use crate::util::test::{TicketFixture, canonical_without_verbatim_prefix};
fn test_update(overrides: &[(&str, serde_json::Value)]) -> serde_json::Value {
let mut update = serde_json::json!({
"update_id": 1,
"message": {
"message_id": 33,
"text": "hello",
"from": { "id": 555, "username": "alice" },
"chat": { "id": -100_200_300 }
}
});
let obj = update.as_object_mut().unwrap();
for (key, value) in overrides {
obj.insert(key.to_string(), value.clone());
}
update
}
async fn test_channel() -> TelegramChannel {
crate::users::test_util::init_test_store().await;
TelegramChannel::new("token".into())
}
#[test]
fn test_parse_recipient() {
assert_eq!(parse_recipient("12345"), ("12345", None));
assert_eq!(parse_recipient("12345:678"), ("12345", Some("678")));
assert_eq!(parse_recipient(""), ("", None));
}
#[test]
fn classify_edit_failure_matches_stable_substrings() {
use EditMessageFailure::{CannotEdit, NotFound, NotModified, Other};
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}"#
),
NotFound
));
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":400,"description":"Bad Request: message not found"}"#
),
NotFound
));
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}"#
),
NotModified
));
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":400,"description":"Bad Request: message can't be edited"}"#
),
CannotEdit
));
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":400,"description":"Bad Request: message cant be edited"}"#
),
CannotEdit
));
assert!(matches!(
classify_edit_failure(
r#"{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 5"}"#
),
Other
));
assert!(matches!(classify_edit_failure(""), Other));
}
#[test]
fn test_markdown_to_telegram_html() {
let r = markdown_to_telegram_html("[click](https://example.com?q=\"x\"&a='b')");
assert_eq!(
r,
"<a href=\"https://example.com?q="x"&a='b'\">click</a>"
);
let r = markdown_to_telegram_html("say \"hi\" & <tag> 'ok'");
assert_eq!(r, "say "hi" & <tag> 'ok'");
let r = markdown_to_telegram_html("```rust\" onclick=\"alert(1)\nlet x = 1;\n```");
assert_eq!(r, "<pre><code>let x = 1;</code></pre>");
assert!(!r.contains("language-"));
assert!(!r.contains("onclick"));
let r = markdown_to_telegram_html("```\nsome **bold** and `code`\n```");
assert_eq!(r, "<pre><code>some **bold** and `code`</code></pre>");
let r = markdown_to_telegram_html("```\n<div> & \"it\" 'works'\n```");
assert_eq!(
r,
"<pre><code><div> & "it" 'works'</code></pre>"
);
let r = markdown_to_telegram_html("```\nuse </code>\n```");
assert_eq!(r, "<pre><code>use &lt;/code&gt;</code></pre>");
let r = markdown_to_telegram_html("<blockquote>");
assert_eq!(r, "<blockquote>");
let r = markdown_to_telegram_html("</blockquote>");
assert_eq!(r, "</blockquote>");
let r = markdown_to_telegram_html("<blockquote>\nHello **world**\n</blockquote>");
assert_eq!(r, "<blockquote>\nHello <b>world</b>\n</blockquote>");
let r = markdown_to_telegram_html("<blockquote123>");
assert_eq!(r, "<blockquote123>");
let r = markdown_to_telegram_html("<blockquote class=\"x\">");
assert_eq!(r, "<blockquote class="x">");
let r = markdown_to_telegram_html("<blockquote >");
assert_eq!(r, "<blockquote >");
let r = markdown_to_telegram_html("## [docs](https://example.com)");
assert_eq!(r, "<a href=\"https://example.com\">docs</a>");
}
#[test]
fn board_listing_isolates_hostile_titles() {
assert_eq!(
format_board_line(&TicketPhase::InDevelopment, "mahbot-123", "Title"),
"🔨 `mahbot-123` Title"
);
let state = TicketPhase::InDevelopment;
let lines = [
format_board_line(&state, "mahbot-1", "Fix * unclosed italic"),
format_board_line(&state, "mahbot-2", "Use `git status` and *pair* ok"),
format_board_line(&state, "mahbot-3", "<script>alert(1)</script> & tags"),
format_board_line(&state, "mahbot-4", "Bold **crash** inside title"),
format_board_line(&state, "mahbot-5", "[link](https://example.com/x?y=1&z=2)"),
format_board_line(&state, "mahbot-6", "backtick ` unclosed"),
format_board_line(&state, "mahbot-7", "Normal ticket"),
];
let listing = lines.join("\n");
let html = markdown_to_telegram_html(&listing);
for line in html.split('\n') {
assert!(line.starts_with("🔨 "), "phase emoji lost: {line:?}");
assert!(
line.contains("<code>") && line.contains("</code> "),
"id formatting lost: {line:?}"
);
}
for (open, close) in [("<b>", "</b>"), ("<i>", "</i>"), ("<code>", "</code>")] {
assert_eq!(
html.matches(open).count(),
html.matches(close).count(),
"unbalanced {open}/{close} in: {html}"
);
}
}
#[test]
fn board_listing_text_speaks_for_all_workspaces() {
assert_eq!(board_listing_text(&[]), "All workspaces — no tickets");
let tickets = [
TicketFixture::new("mahbot-1", TicketPhase::Backlog)
.title("First")
.build(),
TicketFixture::new("mahbot_rlx-2", TicketPhase::InDevelopment)
.title("Second")
.build(),
];
let ordered: Vec<&Ticket> = tickets.iter().collect();
let text = board_listing_text(&ordered);
let mut lines = text.lines();
assert_eq!(lines.next(), Some("All workspaces — 2 tickets"));
for ticket in &tickets {
let line = lines.next().expect("one line per ticket");
assert!(
line.contains(&ticket.id) && line.contains(&ticket.title),
"each entry is its own ticket's id and title, in order: {text:?}"
);
}
assert!(lines.next().is_none(), "nothing is added after the listing");
let one = [&tickets[0]];
assert!(board_listing_text(&one).starts_with("All workspaces — 1 ticket\n"));
}
#[test]
#[expect(clippy::too_many_lines)] fn test_inline_formatting() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static str,
}
let cases = vec now**",
expected: "see <a href=\"https://example.com\">docs</a> now",
},
Case {
name: "italic and strikethrough spans with link",
input: "*[a](https://a.example)* and ~~[b](https://b.example)~~",
expected: "<a href=\"https://a.example\">a</a> and <a href=\"https://b.example\">b</a>",
},
Case {
name: "nested spans with link",
input: "**bold *inner [docs](https://example.com)* tail**",
expected: "bold inner <a href=\"https://example.com\">docs</a> tail",
},
Case {
name: "bold span without link",
input: "**hello [not a link]**",
expected: "<b>hello [not a link]</b>",
},
Case {
name: "bold span around code with link syntax",
input: "**bold `[docs](https://example.com)` tail**",
expected: "<b>bold `[docs](https://example.com)` tail</b>",
},
Case {
name: "marker label is not a link",
input: "[IMAGE:data:image/png;base64,AAAA](https://example.com)",
expected: "[IMAGE:data:image/png;base64,AAAA](https://example.com)",
},
Case {
name: "lowercase marker label is not a link",
input: "[image:/tmp/x.txt](https://example.com)",
expected: "[image:/tmp/x.txt](https://example.com)",
},
Case {
name: "uppercase prose label stays a link",
input: "[NOTE:42](https://example.com)",
expected: "<a href=\"https://example.com\">NOTE:42</a>",
},
];
for case in cases {
let result = markdown_to_telegram_html(case.input);
assert_eq!(result, case.expected, "case: {}", case.name);
}
}
#[tokio::test]
async fn parse_update_message_uses_chat_id_as_reply_target() {
let ch = test_channel().await;
let update = test_update(&[]);
let msg = ch
.parse_update_message(&update)
.await
.expect("message should parse");
assert_eq!(msg.user_name, "alice");
assert_eq!(msg.reply_target, "-100200300");
assert_eq!(msg.content, "hello");
}
fn write_test_png(path: &std::path::Path) {
let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.expect("test PNG must encode");
std::fs::write(path, buf).unwrap();
}
#[test]
fn parse_attachment_markers_tests() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("a.png");
let ogg = dir.path().join("voice.ogg");
let vid = dir.path().join("vid.mp4");
write_test_png(&png);
std::fs::write(&ogg, b"fake-ogg").unwrap();
std::fs::write(&vid, b"fake-mp4").unwrap();
let roots = vec![dir.path().to_path_buf()];
let (cleaned, att, _) = parse_attachment_markers("use `[IMAGE:path]` or `[VIDEO:...]`", &roots);
assert_eq!(cleaned, "use `[IMAGE:path]` or `[VIDEO:...]`");
assert!(att.is_empty());
let (cleaned, att, _) =
parse_attachment_markers(&format!("[IMAGE:{}]", dir.path().display()), &roots);
assert_eq!(cleaned, format!("[IMAGE:{}]", dir.path().display()));
assert!(att.is_empty());
let (cleaned, att, _) = parse_attachment_markers(
&format!(
"Here are files [IMAGE:{}] and [AUDIO:{}]",
png.display(),
ogg.display()
),
&roots,
);
assert_eq!(cleaned, "Here are files and");
assert_eq!(att.len(), 2);
assert_eq!(att[0].kind, TelegramAttachmentKind::Image);
assert_eq!(att[1].kind, TelegramAttachmentKind::Audio);
let (cleaned, att, _) =
parse_attachment_markers("See [VIDEO:https://example.com/vid.mp4]", &roots);
assert_eq!(cleaned, "See");
assert_eq!(att.len(), 1);
assert_eq!(att[0].kind, TelegramAttachmentKind::Video);
assert_eq!(att[0].target, "https://example.com/vid.mp4");
let (cleaned, att, _) = parse_attachment_markers(
&format!("[IMAGE:missing.png] ok [VIDEO:{}]", vid.display()),
&roots,
);
assert_eq!(cleaned, "[IMAGE:missing.png] ok");
assert_eq!(att.len(), 1);
assert_eq!(att[0].kind, TelegramAttachmentKind::Video);
let (cleaned, att, _) = parse_attachment_markers("Report [UNKNOWN:/tmp/a.bin]", &roots);
assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
assert!(att.is_empty());
let (cleaned, att, _) = parse_attachment_markers(&format!("[image:{}]", png.display()), &roots);
assert_eq!(cleaned, "");
assert_eq!(att.len(), 1);
assert_eq!(att[0].kind, TelegramAttachmentKind::Image);
let nonimg = dir.path().join("note.txt");
std::fs::write(&nonimg, b"not an image").unwrap();
let (cleaned, att, _) =
parse_attachment_markers(&format!("[image:{}]", nonimg.display()), &roots);
assert_eq!(cleaned, format!("[image:{}]", nonimg.display()));
assert!(
att.is_empty(),
"non-raster lowercase image marker must stay literal"
);
}
#[test]
fn parse_file_markers_resolve_within_workspace_roots() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let roots = vec![dir.path().to_path_buf()];
let escape = outside.path().join("secret.txt");
std::fs::write(&escape, b"secret").unwrap();
let pdf = dir.path().join("specs.pdf");
std::fs::write(&pdf, b"fake-pdf").unwrap();
let canonical = canonical_without_verbatim_prefix(&pdf);
let (cleaned, att, refusals) =
parse_attachment_markers(&format!("Here: [FILE:{}]", pdf.display()), &roots);
assert_eq!(cleaned, "Here:");
assert!(refusals.is_empty());
assert_eq!(att.len(), 1);
assert_eq!(att[0].kind, TelegramAttachmentKind::Document);
assert_eq!(att[0].target, canonical.to_string_lossy());
let (cleaned, att, refusals) = parse_attachment_markers("[FILE:specs.pdf]", &roots);
assert_eq!(cleaned, "");
assert!(refusals.is_empty());
assert_eq!(att.len(), 1);
assert_eq!(att[0].target, canonical.to_string_lossy());
let other = tempfile::tempdir().unwrap();
let relayed = other.path().join("relayed.pdf");
std::fs::write(&relayed, b"fake-pdf").unwrap();
let (cleaned, att, refusals) = parse_attachment_markers(
"[FILE:relayed.pdf]",
&[dir.path().to_path_buf(), other.path().to_path_buf()],
);
assert_eq!(cleaned, "");
assert!(refusals.is_empty());
assert_eq!(att.len(), 1);
assert_eq!(
att[0].target,
canonical_without_verbatim_prefix(&relayed).to_string_lossy()
);
let (cleaned, att, refusals) =
parse_attachment_markers(&format!("[FILE:{}]", escape.display()), &roots);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"secret.txt\": it is outside your workspace."]
);
let (cleaned, att, refusals) = parse_attachment_markers("[FILE:missing.pdf]", &roots);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"missing.pdf\": file not found."]
);
let sub = dir.path().join("reports");
std::fs::create_dir(&sub).unwrap();
let (_, att, refusals) = parse_attachment_markers(&format!("[FILE:{}]", sub.display()), &roots);
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"reports\": it is a directory."]
);
let (cleaned, att, refusals) = parse_attachment_markers("[FILE:http://x/y.pdf]", &roots);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"y.pdf\": FILE targets are local files, not URLs."]
);
let (cleaned, att, refusals) =
parse_attachment_markers(&format!("[FILE:{}]", pdf.display()), &[]);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"specs.pdf\": no workspace is available for file delivery."]
);
let blob = format!("[FILE:data:application/pdf;base64,{}]", "A".repeat(500));
let (cleaned, att, refusals) = parse_attachment_markers(&blob, &roots);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(refusals.len(), 1);
assert!(refusals[0].ends_with("file not found."), "{refusals:?}");
assert!(
refusals[0].len() < 200,
"the notice must not echo the whole payload: {} bytes",
refusals[0].len()
);
}
#[cfg(unix)]
#[test]
fn parse_file_markers_refuse_symlink_out_of_workspace() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let escape = outside.path().join("secret.txt");
std::fs::write(&escape, b"secret").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&escape, &link).unwrap();
let (cleaned, att, refusals) = parse_attachment_markers(
&format!("[FILE:{}]", link.display()),
&[dir.path().to_path_buf()],
);
assert_eq!(cleaned, "");
assert!(att.is_empty());
assert_eq!(
refusals,
vec!["Could not send \"link.txt\": it is outside your workspace."]
);
}
#[test]
fn bare_path_without_marker_is_plain_text() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("snap.png");
write_test_png(&png);
let path = png.to_string_lossy().to_string();
let (cleaned, att, _) = parse_attachment_markers(&path, &[dir.path().to_path_buf()]);
assert_eq!(cleaned, path);
assert!(att.is_empty());
}
#[test]
fn declared_size_refusal_boundaries() {
const MIB: u64 = 1024 * 1024;
let cases: &[(u64, Option<String>)] = &[
(0, None),
(20 * MIB, None),
(20 * MIB + 1, Some(telegram_download_limit_reason())),
(50 * MIB, Some(telegram_download_limit_reason())),
(50 * MIB + 1, Some(file_too_large_reason())),
];
for (size, expected) in cases {
assert_eq!(declared_size_refusal(*size), *expected, "size {size} bytes");
}
}
#[test]
fn attachment_rejection_content_shapes() {
let reason = telegram_download_limit_reason();
assert_eq!(
attachment_rejection_content(Some("report.pdf"), NOT_RECEIVED, &reason, Some("my report")),
format!("my report\n\n[File report.pdf: not received — {reason}]")
);
assert_eq!(
attachment_rejection_content(Some("report.pdf"), NOT_STORED, &reason, None),
format!("[File report.pdf: could not be stored — {reason}]")
);
assert_eq!(
attachment_rejection_content(None, NOT_RECEIVED, &reason, None),
format!("[File: not received — {reason}]")
);
}
#[tokio::test]
async fn parse_update_message_denies_user_without_username() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 9,
"text": "ping",
"from": {
"id": 555
},
"chat": {
"id": 12345
}
}),
)]);
assert!(
ch.parse_update_message(&update).await.is_none(),
"user without username should be denied"
);
}
#[tokio::test]
async fn parse_update_message_extracts_thread_id_for_forum_topic() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 42,
"text": "hello from topic",
"from": {
"id": 555,
"username": "alice"
},
"chat": {
"id": -100_200_300
},
"message_thread_id": 789
}),
)]);
let msg = ch
.parse_update_message(&update)
.await
.expect("message with thread_id should parse");
assert_eq!(msg.user_name, "alice");
assert_eq!(msg.reply_target, "-100200300:789");
assert_eq!(msg.content, "hello from topic");
}
fn test_callback_query(overrides: &[(&str, serde_json::Value)]) -> serde_json::Value {
let mut cq = serde_json::json!({
"id": "12345",
"data": "set_model|test-model",
"from": { "id": 555, "username": "alice" },
"message": {
"message_id": 100,
"chat": { "id": -100_200_300 },
"date": 1_700_000_000
}
});
let obj = cq.as_object_mut().unwrap();
for (key, value) in overrides {
obj.insert(key.to_string(), value.clone());
}
cq
}
#[tokio::test]
async fn parse_callback_query_returns_message_with_extracted_fields() {
let ch = test_channel().await;
let cq = test_callback_query(&[]);
let msg = ch
.parse_callback_query(&cq)
.await
.expect("callback query should parse with valid user");
assert_eq!(msg.user_name, "alice");
assert_eq!(msg.reply_target, "-100200300");
assert_eq!(msg.content, "set_model|test-model");
assert_eq!(msg.channel, "telegram");
assert_eq!(msg.callback_query_id.as_deref(), Some("12345"));
assert_eq!(msg.chat_id.as_deref(), Some("-100200300"));
assert_eq!(msg.message_id, Some(100));
}
#[tokio::test]
async fn parse_callback_query_rejects_invalid_inputs() {
let ch = test_channel().await;
let unknown_user = serde_json::json!({ "id": 999, "username": "unknown_user" });
let cases = [
("no data", [("data", serde_json::Value::Null)]),
("no message", [("message", serde_json::Value::Null)]),
("unknown user", [("from", unknown_user)]),
];
for (name, overrides) in &cases {
let cq = test_callback_query(overrides);
assert!(
ch.parse_callback_query(&cq).await.is_none(),
"case {name}: expected rejection"
);
}
}
#[tokio::test]
async fn parse_callback_query_accepts_empty_data_and_null_id() {
let ch = test_channel().await;
let cases = [
(
"empty-string data",
[("data", serde_json::json!(""))],
"",
Some("12345"),
),
(
"null id",
[("id", serde_json::Value::Null)],
"set_model|test-model",
None,
),
];
for (name, overrides, content, cq_id) in &cases {
let cq = test_callback_query(overrides);
let msg = ch
.parse_callback_query(&cq)
.await
.unwrap_or_else(|| panic!("case {name}: expected a valid message"));
assert_eq!(msg.content, *content, "case {name}");
assert_eq!(msg.callback_query_id.as_deref(), *cq_id, "case {name}");
}
}
#[test]
fn telegram_message_splitting() {
assert_eq!(
split_message_for_telegram(&"a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH)).len(),
1
);
assert!(split_message_for_telegram(&"a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 1)).len() >= 2);
let long = "a".repeat(5000);
let parts = split_message_for_telegram(&long);
assert!(parts.len() >= 2);
assert_eq!(parts.join(""), long);
assert!(split_message_for_telegram(" \n\n\t ").len() <= 1);
let msg = format!("```python\n{}```\nMore text", "x".repeat(4085));
for p in &split_message_for_telegram(&msg) {
assert!(p.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
}
let msg = format!("{}🎉🎊", "a".repeat(4094));
for p in &split_message_for_telegram(&msg) {
assert!(p.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH);
}
}
#[test]
fn newline_split_fallback_prevents_mid_word_break() {
let msg = format!("{}\n{}", "a".repeat(1000), "x".repeat(5000));
let chunks = split_message_for_telegram(&msg);
for (i, chunk) in chunks.iter().enumerate() {
assert!(
chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
"chunk {} has {} chars (limit {})",
i,
chunk.chars().count(),
TELEGRAM_MAX_MESSAGE_LENGTH,
);
}
assert_eq!(chunks.join(""), msg);
assert!(
chunks[0].ends_with('\n'),
"first chunk should end with newline, got: {:?}",
chunks[0].chars().rev().take(10).collect::<String>()
);
}
#[test]
fn wrapped_chunks_respect_telegram_limit() {
let msg = format!("X{}X", "a".repeat(9000));
let chunks = split_message_for_telegram(&msg);
assert!(
chunks.len() >= 3,
"expected 3+ chunks to exercise all continuation variants"
);
for (i, chunk) in chunks.iter().enumerate() {
let wrapped = wrap_chunk(chunk, i, chunks.len());
assert!(
wrapped.chars().count() <= 4096,
"chunk {} wrapped length {} exceeds 4096",
i,
wrapped.chars().count()
);
}
let boundary = "b".repeat(4066);
let chunks = split_message_for_telegram(&boundary);
assert_eq!(chunks.len(), 1, "4066-char message should not split");
let wrapped = format!("(continued)\n\n{}", chunks[0]);
assert!(
wrapped.chars().count() <= 4096,
"boundary wrapped: {} > 4096",
wrapped.chars().count()
);
let near_limit = "c".repeat(4096);
let chunks = split_message_for_telegram(&near_limit);
assert_eq!(chunks.len(), 1, "4096-char message should not split");
}
#[test]
fn tag_extension_clamped_to_telegram_limit() {
let tag = format!(
"<a href=\"https://example.com/{}\">link</a>",
"y".repeat(120)
);
let ascii_msg = format!("{}{}", "x".repeat(4000), tag);
let cjk_msg = format!("{}{}", "界".repeat(4000), tag);
let cyrillic_msg = format!("{}{}", "ы".repeat(4000), tag);
for (name, msg) in [
("ascii", ascii_msg),
("cjk", cjk_msg),
("cyrillic", cyrillic_msg),
] {
let chunks = split_message_for_telegram(&msg);
assert_eq!(chunks.join(""), msg, "{name}: reconstruction");
assert!(chunks.len() >= 2, "{name}: expected a split");
for (i, chunk) in chunks.iter().enumerate() {
assert!(
chunk.chars().count()
<= TELEGRAM_MAX_MESSAGE_LENGTH - TELEGRAM_CONTINUATION_OVERHEAD,
"{name}: chunk {i} raw {} chars exceeds the 4066 budget",
chunk.chars().count(),
);
let wrapped = wrap_chunk(chunk, i, chunks.len());
assert!(
wrapped.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
"{name}: chunk {i} wrapped {} chars exceeds 4096",
wrapped.chars().count(),
);
}
assert!(
chunks[0].chars().count() >= 4000,
"{name}: first chunk only {} chars — budget underused",
chunks[0].chars().count(),
);
}
}
#[test]
fn test_extract_sender_user_name() {
let username =
extract_sender_user_name(&serde_json::json!({"from": {"id": 123, "username": "alice"}}));
assert_eq!(username, "alice");
let username = extract_sender_user_name(&serde_json::json!({"from": {"id": 42}}));
assert_eq!(username, "unknown");
}
#[tokio::test]
async fn usernameless_sender_stays_unauthorized_despite_legacy_binding() {
let _ch = test_channel().await;
crate::users::store()
.bind_channel("alice", "telegram", "unknown")
.await
.unwrap();
for sender in [
serde_json::json!({"from": {"id": 1}}),
serde_json::json!({"from": {"id": 1, "username": "unknown"}}),
] {
assert!(
resolve_authorized_sender(&sender, &serde_json::json!({"chat": {"id": 42}}))
.await
.is_none(),
"the reserved sentinel must never authorize: {sender}"
);
}
crate::users::store()
.unbind_channel("alice", "telegram", "unknown")
.await
.unwrap();
}
#[tokio::test]
async fn usernameless_sender_is_authorized_by_a_numeric_binding() {
let _ch = test_channel().await;
let store = crate::users::store();
store
.bind_channel("alice", "telegram", "unknown")
.await
.unwrap();
store.add_user("num_user").await.unwrap();
store.bind_telegram("num_user", "31415926").await.unwrap();
assert_eq!(
resolve_authorized_sender(
&serde_json::json!({"from": {"id": 31_415_926}}),
&serde_json::json!({"chat": {"id": 42}}),
)
.await,
Some(("num_user".to_string(), "42".to_string(), "42".to_string())),
);
store
.unbind_channel("num_user", "telegram", "31415926")
.await
.unwrap();
store
.unbind_channel("alice", "telegram", "unknown")
.await
.unwrap();
}
#[tokio::test]
async fn numeric_binding_outranks_a_nickname_bound_to_another_account() {
let _ch = test_channel().await;
let store = crate::users::store();
store.add_user("num_rank").await.unwrap();
store.add_user("nick_rank").await.unwrap();
store.bind_telegram("num_rank", "27182818").await.unwrap();
store.bind_telegram("nick_rank", "!ranknick").await.unwrap();
let authorized = resolve_authorized_sender(
&serde_json::json!({"from": {"id": 27_182_818, "username": "!ranknick"}}),
&serde_json::json!({"chat": {"id": 42}}),
)
.await
.expect("the number binds");
assert_eq!(authorized.0, "num_rank");
store
.unbind_channel("num_rank", "telegram", "27182818")
.await
.unwrap();
store
.unbind_channel("nick_rank", "telegram", "!ranknick")
.await
.unwrap();
}
#[tokio::test]
async fn bot_sender_is_refused_even_when_its_nickname_is_bound() {
let _ch = test_channel().await;
assert!(
resolve_authorized_sender(
&serde_json::json!({
"from": {"id": 424_242, "username": "alice", "is_bot": true}
}),
&serde_json::json!({"chat": {"id": 42}}),
)
.await
.is_none()
);
}
#[tokio::test]
async fn chat_attributed_message_is_refused_even_when_its_sender_is_bound() {
let _ch = test_channel().await;
assert!(
resolve_authorized_sender(
&serde_json::json!({
"sender_chat": {"id": -100_200_300, "type": "channel", "title": "Chan"},
"from": {"id": 424_243, "username": "alice"}
}),
&serde_json::json!({"chat": {"id": 42}}),
)
.await
.is_none()
);
}
#[tokio::test]
async fn telegram_service_identity_senders_are_refused() {
let _ch = test_channel().await;
let cases = [
("anonymous group administrator", 1_087_968_824_i64),
("message attributed to a channel", 136_817_688),
("auto-forwarded channel post", 777_000),
];
for (name, id) in cases {
assert!(
resolve_authorized_sender(
&serde_json::json!({"from": {"id": id, "username": "alice"}}),
&serde_json::json!({"chat": {"id": 42}}),
)
.await
.is_none(),
"case {name}: expected rejection"
);
}
}
#[tokio::test]
async fn callback_query_authorizes_the_person_when_its_message_is_chat_attributed() {
let ch = test_channel().await;
let cq = test_callback_query(&[(
"message",
serde_json::json!({
"message_id": 100,
"sender_chat": {"id": -100_200_300, "type": "channel", "title": "Chan"},
"chat": {"id": -100_200_300}
}),
)]);
let msg = ch
.parse_callback_query(&cq)
.await
.expect("the clicker's own identity authorizes");
assert_eq!(msg.user_name, "alice");
assert_eq!(msg.chat_id.as_deref(), Some("-100200300"));
assert_eq!(msg.message_id, Some(100));
}
#[test]
fn build_reply_reference_text_snippet_normalized() {
let message = serde_json::json!({
"reply_to_message": {
"from": { "username": "alice" },
"text": "say "hi" & <bye>\nsecond line"
}
});
let reply = build_reply_reference(&message).unwrap();
assert_eq!(reply.author, "@alice");
assert_eq!(reply.snippet, "say \"hi\" & bye second line");
}
#[test]
fn build_reply_reference_snippet_caps_at_max() {
let long = "a".repeat(crate::channels::reply::REPLY_SNIPPET_MAX_CHARS + 50);
let message = serde_json::json!({
"reply_to_message": {
"from": { "username": "alice" },
"text": long
}
});
let reply = build_reply_reference(&message).unwrap();
assert_eq!(
reply.snippet.chars().count(),
crate::channels::reply::REPLY_SNIPPET_MAX_CHARS
);
assert!(reply.snippet.ends_with('…'));
}
#[test]
fn build_reply_reference_caption_fallback() {
let message = serde_json::json!({
"reply_to_message": {
"from": { "username": "bob" },
"photo": [{ "file_id": "x", "width": 1, "height": 1 }],
"caption": "at the <beach>"
}
});
let reply = build_reply_reference(&message).unwrap();
assert_eq!(reply.snippet, "at the beach");
}
#[test]
fn build_reply_reference_media_kind_placeholders() {
for (field, expected) in [
("photo", "[Photo]"),
("document", "[Document]"),
("video", "[Video]"),
("voice", "[Voice message]"),
("audio", "[Voice message]"),
("sticker", "[Sticker]"),
("animation", "[Message]"),
] {
let mut reply_to = serde_json::Map::new();
reply_to.insert("from".to_string(), serde_json::json!({ "username": "bob" }));
reply_to.insert(field.to_string(), serde_json::json!({ "file_id": "x" }));
let message = serde_json::json!({ "reply_to_message": reply_to });
let reply = build_reply_reference(&message).unwrap();
assert_eq!(reply.snippet, expected, "field: {field}");
}
let message = serde_json::json!({
"reply_to_message": {
"from": { "username": "bob" },
"video_note": { "file_id": "x" }
}
});
assert_eq!(
build_reply_reference(&message).unwrap().snippet,
"[Message]"
);
}
#[test]
fn build_reply_reference_no_reply_is_none() {
let message = serde_json::json!({ "text": "no reply here" });
assert!(build_reply_reference(&message).is_none());
let message = serde_json::json!({ "text": "hi", "reply_to_message": null });
assert!(build_reply_reference(&message).is_none());
}
#[test]
fn build_reply_reference_mirrored_message() {
let message = serde_json::json!({
"reply_to_message": {
"from": { "username": "alice" },
"text": "<blockquote>\n↩ alice: hi there\n</blockquote>"
}
});
let reply = build_reply_reference(&message).unwrap();
assert_eq!(reply.snippet, "↩ alice: hi there");
}
#[test]
fn replied_to_sender_label_chain() {
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "from": { "username": "alice" } })),
"@alice"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "from": { "id": 1, "first_name": "Bob" } })),
"Bob"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "from": { "id": 1 } })),
"user"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "sender_chat": { "title": "Daily News" } })),
"Daily News"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "sender_chat": { "id": 1 } })),
"channel"
);
assert_eq!(
replied_to_sender_label(
&serde_json::json!({ "from": { "is_bot": true, "username": "poll_bot" } })
),
"@poll_bot"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "from": { "is_bot": true } })),
"bot"
);
assert_eq!(
replied_to_sender_label(&serde_json::json!({ "from": { "first_name": "<Bob>" } })),
"Bob"
);
assert_eq!(replied_to_sender_label(&serde_json::json!({})), "user");
}
#[tokio::test]
async fn parse_update_message_populates_reply_reference() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 10,
"text": "translate this",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 100, "type": "private" },
"reply_to_message": {
"from": { "username": "bot" },
"text": "Bonjour le monde"
}
}),
)]);
let parsed = ch.parse_update_message(&update).await.unwrap();
assert_eq!(parsed.content, "translate this");
let reply = parsed
.reply_reference
.expect("parsed message should carry the reply reference");
assert_eq!(reply.author, "@bot");
assert_eq!(reply.snippet, "Bonjour le monde");
}
#[tokio::test]
async fn attachment_message_reply_carries_reference() {
let ch = test_channel().await;
let message = serde_json::json!({
"message_id": 7,
"from": { "id": 1, "username": "alice" },
"chat": { "id": 100, "type": "private" },
"photo": [{ "file_id": "x", "width": 1, "height": 1 }],
"reply_to_message": {
"from": { "username": "bob" },
"photo": [{ "file_id": "y", "width": 1, "height": 1 }]
}
});
let ctx = ch
.extract_message_context(&message)
.await
.expect("valid context");
let reply = ctx.reply_reference.expect("media reply carries reference");
assert_eq!(reply.author, "@bob");
assert_eq!(reply.snippet, "[Photo]");
assert_eq!(ctx.chat_id, "100");
assert_eq!(ctx.message_id, 7);
}
#[tokio::test]
async fn reply_that_is_a_command_parses_as_command() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 11,
"text": "/clear",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 100, "type": "private" },
"reply_to_message": {
"from": { "username": "bot" },
"text": "some earlier text"
}
}),
)]);
let parsed = ch.parse_update_message(&update).await.unwrap();
assert_eq!(parsed.content, "/clear");
assert!(crate::parse_bot_command(&parsed.content).is_some());
}
#[tokio::test]
async fn reply_to_command_keeps_reference() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 12,
"text": "why did you clear?",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 100, "type": "private" },
"reply_to_message": {
"from": { "username": "bot" },
"text": "/clear"
}
}),
)]);
let parsed = ch.parse_update_message(&update).await.unwrap();
assert_eq!(parsed.content, "why did you clear?");
assert!(crate::parse_bot_command(&parsed.content).is_none());
let reply = parsed
.reply_reference
.expect("non-command reply keeps its reference");
assert_eq!(reply.snippet, "/clear");
}
#[test]
fn test_parse_attachment_metadata() {
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"document": {"file_id": "BQ", "file_name": "report.pdf", "file_size": 12345, "mime_type": "application/pdf"}
}))
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Document);
assert_eq!(att.file_id, "BQ");
assert_eq!(att.file_name.as_deref(), Some("report.pdf"));
assert_eq!(att.file_size, Some(12345));
assert_eq!(att.mime_type.as_deref(), Some("application/pdf"));
assert!(att.caption.is_none());
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"photo": [{"file_id": "small_id", "file_size": 100}, {"file_id": "large_id", "file_size": 2000}]
})).unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Photo);
assert_eq!(att.file_id, "large_id");
assert_eq!(att.file_size, Some(2000));
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"document": {"file_id": "doc_id", "file_name": "data.csv"}, "caption": "Monthly report"
}))
.unwrap();
assert_eq!(att.caption.as_deref(), Some("Monthly report"));
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"photo": [{"file_id": "photo_id", "file_size": 1000}], "caption": "Look at this"
}))
.unwrap();
assert_eq!(att.caption.as_deref(), Some("Look at this"));
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"document": {"file_id": "doc_no_name"}
}))
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Document);
assert_eq!(att.file_id, "doc_no_name");
assert!(att.file_name.is_none());
assert!(att.file_size.is_none());
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"document": {"file_id": "img_doc", "mime_type": "image/png"}
}))
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Document);
assert_eq!(att.mime_type.as_deref(), Some("image/png"));
let att = TelegramChannel::parse_attachment_metadata(
&serde_json::json!({"voice": {"file_id": "v", "duration": 5}}),
)
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Voice);
assert_eq!(att.file_id, "v");
assert!(att.file_name.is_none());
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"video": {"file_id": "vid", "file_name": "clip.mp4", "file_size": 12345, "mime_type": "video/mp4", "duration": 4}
}))
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Video);
assert_eq!(att.file_id, "vid");
assert_eq!(att.file_name.as_deref(), Some("clip.mp4"));
assert_eq!(att.file_size, Some(12345));
assert_eq!(att.mime_type.as_deref(), Some("video/mp4"));
let att = TelegramChannel::parse_attachment_metadata(
&serde_json::json!({"video_note": {"file_id": "vn", "duration": 3, "file_size": 999}}),
)
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Video);
assert_eq!(att.file_id, "vn");
assert!(att.file_name.is_none());
assert!(att.mime_type.is_none());
let att = TelegramChannel::parse_attachment_metadata(&serde_json::json!({
"animation": {"file_id": "anim", "file_name": "sticker.gif", "mime_type": "video/mp4"}
}))
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Video);
assert_eq!(att.file_id, "anim");
assert_eq!(att.file_name.as_deref(), Some("sticker.gif"));
let att = TelegramChannel::parse_attachment_metadata(
&serde_json::json!({"audio": {"file_id": "a", "file_name": "song.mp3", "file_size": 999}}),
)
.unwrap();
assert_eq!(att.kind, IncomingAttachmentKind::Audio);
assert_eq!(att.file_id, "a");
assert_eq!(att.file_name.as_deref(), Some("song.mp3"));
assert_eq!(att.file_size, Some(999));
assert!(
TelegramChannel::parse_attachment_metadata(&serde_json::json!({"text": "Hello"})).is_none()
);
assert!(
TelegramChannel::parse_attachment_metadata(&serde_json::json!({"photo": []})).is_none()
);
}
#[test]
#[expect(clippy::too_many_lines)]
fn attachment_content_format_rules() {
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
std::path::Path::new("/tmp/workspace/photo.jpg"),
None,
);
assert_eq!(c, "[IMAGE:/tmp/workspace/photo.jpg]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/report.pdf"),
None,
);
assert_eq!(c, "[FILE:/tmp/workspace/report.pdf]");
assert!(!c.contains("[IMAGE:"));
let dir = tempfile::tempdir().unwrap();
let nameless = dir.path().join("file_1_2.bin");
write_test_png(&nameless);
let c = format_attachment_content(IncomingAttachmentKind::Document, &nameless, None);
assert_eq!(c, format!("[IMAGE:{}]", nameless.display()));
let blob = dir.path().join("file_1_3.bin");
std::fs::write(&blob, b"definitely not a raster").unwrap();
let c = format_attachment_content(IncomingAttachmentKind::Document, &blob, None);
assert_eq!(c, format!("[FILE:{}]", blob.display()));
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
std::path::Path::new("/tmp/workspace/notes.md"),
None,
);
assert!(!c.contains("[IMAGE:"));
assert!(c.starts_with("[FILE:"));
for path in [
"/tmp/workspace/file.md",
"/tmp/workspace/file.txt",
"/tmp/workspace/file.pdf",
"/tmp/workspace/file.csv",
"/tmp/workspace/file.json",
"/tmp/workspace/file.zip",
"/tmp/workspace/file",
] {
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
std::path::Path::new(path),
None,
);
assert!(!c.contains("[IMAGE:"), "{path}: should not get [IMAGE:]");
assert!(c.starts_with("[FILE:"), "{path}: should use [FILE:]");
}
for ext in ["png", "jpg", "jpeg", "webp"] {
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
std::path::Path::new(&format!("/tmp/workspace/photo.{ext}")),
None,
);
assert!(c.starts_with("[IMAGE:"), "{ext}: should get [IMAGE:]");
}
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/image.jpg"),
None,
);
assert_eq!(c, "[IMAGE:/tmp/workspace/image.jpg]");
for mime in ["image/jpeg", "image/jpg"] {
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/image_no_ext"),
Some(mime),
);
assert_eq!(c, "[IMAGE:/tmp/workspace/image_no_ext]", "{mime}");
}
for mime in ["image/gif", "image/bmp", "image/x-ms-bmp"] {
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/anim_no_ext"),
Some(mime),
);
assert!(!c.contains("[IMAGE:"), "{mime}: should not get [IMAGE:]");
assert!(c.starts_with("[FILE:"), "{mime}: should use [FILE:]");
}
let audio_marker = |path: &str| {
if cfg!(target_os = "macos") {
format!("[AUDIO:{path}]")
} else {
format!("[FILE:{path}]")
}
};
let c = format_attachment_content(
IncomingAttachmentKind::Audio,
std::path::Path::new("/tmp/workspace/voice.ogg"),
None,
);
assert_eq!(c, audio_marker("/tmp/workspace/voice.ogg"));
let c = format_attachment_content(
IncomingAttachmentKind::Audio,
std::path::Path::new("/tmp/workspace/song.mp3"),
Some("audio/mpeg"),
);
assert_eq!(c, audio_marker("/tmp/workspace/song.mp3"));
let c = format_attachment_content(
IncomingAttachmentKind::Video,
std::path::Path::new("/tmp/workspace/clip.mp4"),
None,
);
assert_eq!(c, "[VIDEO:/tmp/workspace/clip.mp4]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/clip.mp4"),
None,
);
assert_eq!(c, "[VIDEO:/tmp/workspace/clip.mp4]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
std::path::Path::new("/tmp/workspace/clip_no_ext"),
Some("video/mp4"),
);
assert_eq!(c, "[VIDEO:/tmp/workspace/clip_no_ext]");
let c = format_attachment_content(
IncomingAttachmentKind::Video,
std::path::Path::new("/tmp/workspace/notes.txt"),
None,
);
assert_eq!(c, "[FILE:/tmp/workspace/notes.txt]");
}
#[test]
fn sanitize_attachment_filename_neutralizes_traversal() {
let fallback = "file_1_2.pdf";
assert_eq!(
sanitize_attachment_filename("../../etc/passwd", fallback),
"passwd"
);
assert_eq!(
sanitize_attachment_filename("/etc/passwd", fallback),
"passwd"
);
for rejected in ["..", ".", "", "/", "a/.."] {
assert_eq!(
sanitize_attachment_filename(rejected, fallback),
fallback,
"{rejected:?}"
);
}
assert_eq!(
sanitize_attachment_filename("..", "we]ird[\u{7}name.bin"),
"we_ird__name.bin"
);
assert_eq!(
sanitize_attachment_filename("C:\\evil\\x.txt", fallback),
"C:_evil_x.txt"
);
assert_eq!(
sanitize_attachment_filename("a\nb\r\t.txt", fallback),
"a_b__.txt"
);
assert_eq!(
sanitize_attachment_filename("notes]v2[1].pdf", fallback),
"notes_v2_1_.pdf"
);
assert_eq!(
sanitize_attachment_filename(".bashrc", fallback),
"_.bashrc"
);
let long = format!("{}.pdf", "x".repeat(500));
let sanitized = sanitize_attachment_filename(&long, fallback);
assert_eq!(sanitized, format!("{}.pdf", "x".repeat(176)));
let long_ext = format!(".{}", "y".repeat(200));
let dotted = sanitize_attachment_filename(&long_ext, fallback);
assert!(dotted.starts_with("_."), "{dotted}");
assert!(
dotted.len() <= MAX_ATTACHMENT_FILENAME_BYTES,
"{dotted} is {} bytes",
dotted.len()
);
let huge_ext = format!("call.log.{}", "y".repeat(200));
let capped = sanitize_attachment_filename(&huge_ext, fallback);
assert_eq!(capped.len(), MAX_ATTACHMENT_FILENAME_BYTES);
assert!(capped.starts_with("call.log."), "{capped}");
let narrow = format!("🙂.{}", "y".repeat(177));
let squeezed = sanitize_attachment_filename(&narrow, fallback);
assert!(!squeezed.starts_with('.'), "{squeezed}");
assert_eq!(squeezed.len(), MAX_ATTACHMENT_FILENAME_BYTES);
}
#[test]
fn sanitize_attachment_filename_caps_bytes_not_chars() {
let fallback = "file_1_2.bin";
for (unit, stem_len) in [('漢', 58), ('🙂', 44)] {
let long = format!("{}.png", unit.to_string().repeat(200));
let sanitized = sanitize_attachment_filename(&long, fallback);
assert!(
sanitized.len() <= MAX_ATTACHMENT_FILENAME_BYTES,
"{sanitized} is {} bytes",
sanitized.len()
);
assert_eq!(
sanitized,
format!("{}.png", unit.to_string().repeat(stem_len)),
"{unit}"
);
}
}
#[test]
fn local_attachment_name_uses_sanitized_sender_name_or_fallback() {
let attachment = IncomingAttachment {
file_id: "f".to_string(),
file_name: Some("../../etc/passwd".to_string()),
file_size: None,
caption: None,
kind: IncomingAttachmentKind::Document,
mime_type: None,
};
assert_eq!(local_attachment_name(&attachment, "123", 7, None), "passwd");
let generated = local_attachment_name(
&IncomingAttachment {
file_name: None,
kind: IncomingAttachmentKind::Video,
..attachment.clone()
},
"123",
7,
None,
);
assert_eq!(generated, "video_123_7.mp4");
for degenerate in ["..", ".", ""] {
assert_eq!(
local_attachment_name(
&IncomingAttachment {
file_name: Some(degenerate.to_string()),
kind: IncomingAttachmentKind::Video,
..attachment.clone()
},
"123",
7,
Some("mp4"),
),
"video_123_7.mp4",
"name {degenerate:?}"
);
}
assert_eq!(
local_attachment_name(
&IncomingAttachment {
file_name: None,
..attachment
},
"123",
7,
None
),
"file_123_7.bin"
);
}
#[test]
fn local_attachment_name_sanitizes_remote_extension_fallback() {
let attachment = IncomingAttachment {
file_id: "f".to_string(),
file_name: None,
file_size: None,
caption: None,
kind: IncomingAttachmentKind::Document,
mime_type: None,
};
let hostile = "..]x/漢\u{7}evil\u{202e}png";
let name = local_attachment_name(&attachment, "123", 7, Some(hostile));
assert_eq!(name, "file_123_7.xevilpng");
let name = local_attachment_name(&attachment, "123", 7, Some("]/漢\u{7}"));
assert_eq!(name, "file_123_7.bin");
let long_ext = "a".repeat(500);
let name = local_attachment_name(&attachment, "123", 7, Some(long_ext.as_str()));
assert_eq!(name.len(), MAX_ATTACHMENT_FILENAME_BYTES);
assert!(name.starts_with("file_123_7."), "{name}");
}
#[test]
fn attachment_image_and_video_helpers() {
for p in [
"photo.png",
"photo.jpg",
"photo.jpeg",
"photo.webp",
"PHOTO.PNG",
] {
assert!(crate::util::has_extension(
std::path::Path::new(p),
super::IMAGE_EXTENSIONS
));
}
for p in ["photo.gif", "photo.bmp"] {
assert!(!crate::util::has_extension(
std::path::Path::new(p),
super::IMAGE_EXTENSIONS
));
}
for p in ["file.md", "file.txt", "file.pdf", "file.csv", "file"] {
assert!(!crate::util::has_extension(
std::path::Path::new(p),
super::IMAGE_EXTENSIONS
));
}
for p in [
"clip.mp4",
"clip.mov",
"clip.mkv",
"clip.avi",
"clip.webm",
"CLIP.MP4",
] {
assert!(crate::util::is_video_extension(std::path::Path::new(p)));
}
for p in ["file.md", "file.png", "file", "clip.mpg"] {
assert!(!crate::util::is_video_extension(std::path::Path::new(p)));
}
}
#[test]
fn video_filename_normalization() {
assert_eq!(
normalize_video_filename(
IncomingAttachmentKind::Video,
"tenor.gif",
Some("video/mp4")
),
"tenor.mp4"
);
assert_eq!(
normalize_video_filename(
IncomingAttachmentKind::Video,
"clip.webm",
Some("video/webm")
),
"clip.webm"
);
assert_eq!(
normalize_video_filename(IncomingAttachmentKind::Video, "video_123_45", None),
"video_123_45.mp4"
);
assert_eq!(
normalize_video_filename(IncomingAttachmentKind::Document, "tenor.gif", None),
"tenor.gif"
);
assert_eq!(
normalize_video_filename(
IncomingAttachmentKind::Document,
"clip.xyz",
Some("video/mp4")
),
"clip.mp4"
);
assert_eq!(
normalize_video_filename(
IncomingAttachmentKind::Document,
"clip.xyz",
Some("video/webm")
),
"clip.webm"
);
}
fn album_member_update(message_id: i64, group: &str) -> serde_json::Value {
test_update(&[(
"message",
serde_json::json!({ "message_id": message_id, "media_group_id": group }),
)])
}
fn album_button_press_update(group: &str) -> serde_json::Value {
test_update(&[(
"callback_query",
test_callback_query(&[(
"message",
serde_json::json!({ "message_id": 42, "media_group_id": group }),
)]),
)])
}
#[test]
fn poll_plan_parses_each_album_at_its_first_member() {
let updates = vec![
album_member_update(41, "g1"),
test_update(&[]),
album_member_update(42, "g1"),
album_member_update(43, "g2"),
];
assert_eq!(
poll_plan(&updates),
vec![
PollPlacement::Album(vec![0, 2]),
PollPlacement::Single,
PollPlacement::Skip,
PollPlacement::Album(vec![3]),
]
);
}
#[test]
fn poll_plan_keeps_a_button_press_out_of_its_album() {
let press_first = vec![
album_button_press_update("g1"),
album_member_update(42, "g1"),
album_member_update(43, "g1"),
];
assert_eq!(
poll_plan(&press_first),
vec![
PollPlacement::Callback,
PollPlacement::Album(vec![1, 2]),
PollPlacement::Skip,
]
);
let press_between = vec![
album_member_update(41, "g1"),
album_button_press_update("g1"),
album_member_update(43, "g1"),
];
assert_eq!(
poll_plan(&press_between),
vec![
PollPlacement::Album(vec![0, 2]),
PollPlacement::Callback,
PollPlacement::Skip,
]
);
}
#[test]
fn album_members_merge_into_one_message() {
let first_chat = "-100";
let second_chat = "-200";
let first = ChannelMessage {
message_id: Some(41),
chat_id: Some(first_chat.into()),
content: "a".into(),
attachment_dirs: vec![crate::util::telegram_staging_dir_name(first_chat, 41)],
..test_msg("alice", "", "telegram", "chat")
};
let second = ChannelMessage {
message_id: Some(42),
chat_id: Some(second_chat.into()),
content: "b".into(),
attachment_dirs: vec![
crate::util::telegram_staging_dir_name(second_chat, 42),
crate::util::telegram_staging_dir_name(second_chat, 43),
],
..test_msg("bob", "", "telegram", "other")
};
let album = merge_album_member(None, first.clone());
assert_eq!(album.content, "a");
assert_eq!(album.attachment_dirs, first.attachment_dirs);
let album = merge_album_member(Some(album), second);
assert_eq!(album.content, "a\nb");
assert_eq!(
album.attachment_dirs,
vec![
crate::util::telegram_staging_dir_name(first_chat, 41),
crate::util::telegram_staging_dir_name(second_chat, 42),
crate::util::telegram_staging_dir_name(second_chat, 43),
]
);
}
#[test]
fn is_control_message_covers_commands_and_callbacks() {
assert!(is_control_message(&test_msg(
"alice", "/pause", "telegram", "chat"
)));
assert!(is_control_message(&test_msg(
"alice",
"/maintenance on",
"telegram",
"chat"
)));
assert!(is_control_message(&test_msg(
"alice",
"/image_models",
"telegram",
"chat"
)));
assert!(is_control_message(&test_msg(
"alice",
"__act__set_workspace|ws",
"telegram",
"chat"
)));
let mut callback = test_msg("alice", "anything", "telegram", "chat");
callback.callback_query_id = Some("1".to_string());
assert!(is_control_message(&callback));
assert!(control_input(&callback).is_none());
assert!(!is_control_message(&test_msg(
"alice", "/pause", "gui", "chat"
)));
assert!(!is_control_message(&test_msg(
"alice", "hello", "telegram", "chat"
)));
assert!(!is_control_message(&test_msg(
"alice",
"/etc/hosts",
"telegram",
"chat"
)));
}
#[tokio::test]
async fn forward_attribution() {
let ch = test_channel().await;
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 50,
"text": "Check this out",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 },
"forward_from": {
"id": 42,
"first_name": "Bob",
"username": "bob"
},
"forward_date": 1_700_000_000
}),
)]);
let msg = ch.parse_update_message(&update).await.unwrap();
assert_eq!(msg.content, "[Forwarded from @bob] Check this out");
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 51,
"text": "Breaking news",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 },
"forward_from_chat": {
"id": -1_001_234_567_890_i64,
"title": "Daily News",
"username": "dailynews",
"type": "channel"
},
"forward_date": 1_700_000_000
}),
)]);
let msg = ch.parse_update_message(&update).await.unwrap();
assert_eq!(
msg.content,
"[Forwarded from channel: Daily News] Breaking news"
);
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 52,
"text": "Secret tip",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 },
"forward_sender_name": "Hidden User",
"forward_date": 1_700_000_000
}),
)]);
let msg = ch.parse_update_message(&update).await.unwrap();
assert_eq!(msg.content, "[Forwarded from Hidden User] Secret tip");
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 53,
"text": "Normal message",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 }
}),
)]);
let msg = ch.parse_update_message(&update).await.unwrap();
assert_eq!(msg.content, "Normal message");
let update = test_update(&[(
"message",
serde_json::json!({
"message_id": 54,
"text": "Hello there",
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 },
"forward_from": {
"id": 77,
"first_name": "Charlie"
},
"forward_date": 1_700_000_000
}),
)]);
let msg = ch.parse_update_message(&update).await.unwrap();
assert_eq!(msg.content, "[Forwarded from Charlie] Hello there");
let message = serde_json::json!({
"message_id": 60,
"from": { "id": 1, "username": "alice" },
"chat": { "id": 999 },
"photo": [
{ "file_id": "abc123", "file_unique_id": "u1", "width": 320, "height": 240 }
],
"forward_from": {
"id": 42,
"username": "bob"
},
"forward_date": 1_700_000_000
});
let attr =
TelegramChannel::format_forward_attribution(&message).expect("should detect forward");
assert_eq!(attr, "[Forwarded from @bob] ");
let photo_content = "[IMAGE:/tmp/photo.jpg]".to_string();
let content = format!("{attr}{photo_content}");
assert_eq!(content, "[Forwarded from @bob] [IMAGE:/tmp/photo.jpg]");
}
#[test]
fn test_strip_html_tags() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static str,
}
let cases = vec![
Case {
name: "empty string",
input: "",
expected: "",
},
Case {
name: "plain text",
input: "hello world",
expected: "hello world",
},
Case {
name: "simple tag",
input: "<b>bold</b>",
expected: "bold",
},
Case {
name: "nested tags",
input: "<div><span>text</span></div>",
expected: "text",
},
Case {
name: "self-closing tag",
input: "before<br/>after",
expected: "beforeafter",
},
Case {
name: "gt in double-quoted attribute",
input: "<a title=\"a > b\">link</a>",
expected: "link",
},
Case {
name: "gt in single-quoted attribute",
input: "<a title='a > b'>link</a>",
expected: "link",
},
Case {
name: "mixed quotes - double with single inside",
input: "<a title=\"he said 'hello'\">text</a>",
expected: "text",
},
Case {
name: "mixed quotes - single with double inside",
input: "<a title='he said \"hello\"'>text</a>",
expected: "text",
},
Case {
name: "multiple attrs with gt",
input: "<input type=\"text\" value=\"a > b\" placeholder=\"x > y\">",
expected: "",
},
Case {
name: "gt outside tag",
input: "a > b",
expected: "a > b",
},
Case {
name: "lt outside tag",
input: "a < b",
expected: "a ",
},
Case {
name: "html comment",
input: "<!-- comment -->visible",
expected: "visible",
},
Case {
name: "mixed content",
input: "Hello <b>world</b>, check <a href=\"https://example.com?q=a > b\">this</a> out!",
expected: "Hello world, check this out!",
},
];
for case in cases {
let result = strip_html_tags(case.input);
assert_eq!(result, case.expected, "case: {}", case.name);
}
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_extend_past_open_tag() {
struct Case {
name: &'static str,
input: &'static str,
pos: usize,
expected: Option<usize>,
}
let cases = vec![
Case {
name: "no tag near pos",
input: "hello world",
pos: 5,
expected: None,
},
Case {
name: "inside simple tag before gt",
input: "<b>hello",
pos: 1,
expected: Some(3),
},
Case {
name: "inside simple tag at gt",
input: "<b>hello",
pos: 2,
expected: Some(3),
},
Case {
name: "after simple tag at h",
input: "<b>hello",
pos: 3,
expected: None,
},
Case {
name: "after simple tag further",
input: "<b>hello",
pos: 5,
expected: None,
},
Case {
name: "no closing gt",
input: "<div",
pos: 3,
expected: None,
},
Case {
name: "gt in double-quoted attr before real gt",
input: "<a title=\"a > b\">text",
pos: 13,
expected: Some(17),
},
Case {
name: "gt in double-quoted attr at real gt",
input: "<a title=\"a > b\">text",
pos: 16,
expected: Some(17),
},
Case {
name: "after closed tag with gt in attr at 17",
input: "<a title=\"a > b\">text",
pos: 17,
expected: None,
},
Case {
name: "after closed tag with gt in attr at 20",
input: "<a title=\"a > b\">text",
pos: 20,
expected: None,
},
Case {
name: "gt in single-quoted attr",
input: "<a title='a > b'>text",
pos: 13,
expected: Some(17),
},
Case {
name: "mixed quotes",
input: "<a title=\"he said 'stop'\">text",
pos: 17,
expected: Some(26),
},
Case {
name: "after nested tags at 11",
input: "<div><span>text",
pos: 11,
expected: None,
},
Case {
name: "after nested tags at 15",
input: "<div><span>text",
pos: 15,
expected: None,
},
Case {
name: "inside nested tag",
input: "<div><span>text",
pos: 6,
expected: Some(11),
},
Case {
name: "pos at start",
input: "<b>text",
pos: 0,
expected: None,
},
];
for case in cases {
let result = extend_past_open_tag(case.input, case.pos);
assert_eq!(result, case.expected, "case: {}", case.name);
}
}
#[test]
fn test_decode_action() {
struct Case {
name: &'static str,
input: &'static str,
expected: Option<(&'static str, &'static str)>,
}
let cases = [
Case {
name: "with payload",
input: "__act__set_image_model|google/gemini-3.1-flash-image-preview",
expected: Some(("set_image_model", "google/gemini-3.1-flash-image-preview")),
},
Case {
name: "empty payload pipe",
input: "__act__clear_session|",
expected: Some(("clear_session", "")),
},
Case {
name: "no pipe",
input: "__act__clear_session",
expected: Some(("clear_session", "")),
},
Case {
name: "rejects non prefix",
input: "random_text",
expected: None,
},
Case {
name: "rejects empty",
input: "",
expected: None,
},
];
for case in &cases {
let result = decode_action(case.input);
let expected = case
.expected
.map(|(action, payload)| (action.to_string(), payload.to_string()));
assert_eq!(result, expected, "case: {}", case.name);
}
}
use crate::util::UnwrapPoison;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
static MIRROR_TEST_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
async fn acquire_mirror_lock() -> tokio::sync::MutexGuard<'static, ()> {
MIRROR_TEST_LOCK
.get_or_init(|| tokio::sync::Mutex::new(()))
.lock()
.await
}
fn setup_spy_channel() -> &'static Arc<Mutex<Vec<SendMessage>>> {
static SPY_SENT: OnceLock<Arc<Mutex<Vec<SendMessage>>>> = OnceLock::new();
SPY_SENT.get_or_init(|| {
let (spy, sent) = crate::util::test::SpyChannel::new("telegram");
let registry = crate::CHANNEL_REGISTRY.get_or_init(crate::ChannelRegistry::default);
registry.register(Arc::new(spy) as Arc<dyn crate::Channel>);
sent
})
}
async fn setup_user_with_telegram_binding(user_name: &str, reply_target: &str, ctx: &str) {
use crate::users::store;
let store = store();
store
.add_user(user_name)
.await
.unwrap_or_else(|e| panic!("{ctx}: add_user: {e}"));
store
.bind_channel(user_name, "telegram", user_name)
.await
.unwrap_or_else(|e| panic!("{ctx}: bind_channel: {e}"));
store
.update_channel_contact("telegram", user_name, reply_target)
.await
.unwrap_or_else(|e| panic!("{ctx}: update_channel_contact: {e}"));
}
async fn setup_mirror_test_env() -> (
&'static Arc<Mutex<Vec<SendMessage>>>,
tokio::sync::MutexGuard<'static, ()>,
) {
let lock = acquire_mirror_lock().await;
crate::util::test::init_test_stores().await;
let sent = setup_spy_channel();
(sent, lock)
}
fn test_msg(user_name: &str, content: &str, channel: &str, reply_target: &str) -> ChannelMessage {
ChannelMessage {
user_name: user_name.to_string(),
reply_target: reply_target.to_string(),
content: content.to_string(),
channel: channel.to_string(),
workspace: "test".to_string(),
optimistic_id: None,
callback_query_id: None,
reply_reference: None,
chat_id: None,
message_id: None,
attachment_dirs: Vec::new(),
parts: Vec::new(),
}
}
fn gui_msg(user_name: &str, content: &str) -> ChannelMessage {
test_msg(user_name, content, "gui", "")
}
fn telegram_msg(user_name: &str, content: &str) -> ChannelMessage {
test_msg(user_name, content, "telegram", "chat:thread")
}
fn voice_msg(user_name: &str, content: &str) -> ChannelMessage {
test_msg(user_name, content, "voice", "")
}
async fn mirror_single_to_binding(
user_name: &str,
reply_target: &str,
msg: &ChannelMessage,
) -> SendMessage {
let (sent, _lock) = setup_mirror_test_env().await;
setup_user_with_telegram_binding(user_name, reply_target, user_name).await;
super::mirror_gui_message_to_telegram(msg).await;
let guard = sent.lock().unwrap_poison();
let our_msgs: Vec<_> = guard
.iter()
.filter(|m| m.recipient == reply_target)
.collect();
assert_eq!(our_msgs.len(), 1, "expected exactly one message");
our_msgs[0].clone()
}
enum MirrorSkipSetup {
BoundTo(&'static str, &'static str),
Unbound(&'static str),
BoundNoTarget(&'static str),
}
async fn assert_mirror_skips(setup: MirrorSkipSetup, msg: &ChannelMessage, reason: &str) {
let (sent, _lock) = setup_mirror_test_env().await;
let (user, filter_recipient) = match setup {
MirrorSkipSetup::BoundTo(u, t) => {
setup_user_with_telegram_binding(u, t, &format!("case {reason}")).await;
(u, t)
}
MirrorSkipSetup::Unbound(u) => {
let s = crate::users::store();
s.add_user(u)
.await
.unwrap_or_else(|e| panic!("case {reason}: add_user: {e}"));
(u, u)
}
MirrorSkipSetup::BoundNoTarget(u) => {
let s = crate::users::store();
s.add_user(u)
.await
.unwrap_or_else(|e| panic!("case {reason}: add_user: {e}"));
s.bind_channel(u, "telegram", u)
.await
.unwrap_or_else(|e| panic!("case {reason}: bind_channel: {e}"));
(u, u)
}
};
assert_eq!(msg.user_name, user, "case {reason}");
super::mirror_gui_message_to_telegram(msg).await;
let guard = sent.lock().unwrap_poison();
let our_msgs: Vec<_> = guard
.iter()
.filter(|m| m.recipient == filter_recipient)
.collect();
assert!(
our_msgs.is_empty(),
"case {reason}: got {} message(s)",
our_msgs.len()
);
}
#[tokio::test]
async fn mirror_skips_guard_cases() {
assert_mirror_skips(
MirrorSkipSetup::BoundTo("skip_telegram", "target_non_gui"),
&telegram_msg("skip_telegram", "hello from telegram"),
"Telegram-originated messages should not send (voice is the only non-GUI source accepted)",
)
.await;
assert_mirror_skips(
MirrorSkipSetup::BoundTo("skip_ew", "target_empty_ws"),
&gui_msg("skip_ew", ""),
"empty content should not send",
)
.await;
assert_mirror_skips(
MirrorSkipSetup::BoundTo("skip_ew", "target_empty_ws"),
&gui_msg("skip_ew", " \t\n "),
"whitespace content should not send",
)
.await;
assert_mirror_skips(
MirrorSkipSetup::Unbound("no_binding"),
&gui_msg("no_binding", "hello"),
"user with no bindings should not send",
)
.await;
assert_mirror_skips(
MirrorSkipSetup::BoundNoTarget("no_target"),
&gui_msg("no_target", "hello"),
"binding without reply_target should not send",
)
.await;
assert_mirror_skips(
MirrorSkipSetup::BoundTo("media_only", "target_media"),
&gui_msg("media_only", "[IMAGE:/path/to/img.png]"),
"media-only content should not send",
)
.await;
let mut empty_ref = gui_msg("skip_ew", "");
empty_ref.reply_reference = Some(crate::channels::ReplyReference {
author: "alice".to_string(),
snippet: "hi".to_string(),
});
assert_mirror_skips(
MirrorSkipSetup::BoundTo("skip_ew", "target_empty_ws"),
&empty_ref,
"empty content with a reply reference should not send",
)
.await;
}
#[tokio::test]
async fn sends_blockquote_to_single_binding() {
let mirrored = mirror_single_to_binding(
"single_user",
"unique_single",
&gui_msg("single_user", "Hello, world!"),
)
.await;
assert_eq!(
mirrored.content,
"<blockquote>\nHello, world!\n</blockquote>"
);
assert!(mirrored.reply_markup.is_none());
}
#[tokio::test]
async fn mirrors_voice_transcript_to_telegram() {
let mirrored = mirror_single_to_binding(
"voice_user",
"unique_voice",
&voice_msg("voice_user", "Record this voice note"),
)
.await;
assert_eq!(
mirrored.content,
"<blockquote>\nRecord this voice note\n</blockquote>"
);
assert!(mirrored.reply_markup.is_none());
}
#[tokio::test]
async fn sends_to_multiple_telegram_bindings() {
let (sent, _lock) = setup_mirror_test_env().await;
let store = crate::users::store();
store.add_user("multi_user").await.expect("add_user");
store
.bind_channel("multi_user", "telegram", "multi_user_1")
.await
.expect("bind_channel_1");
store
.bind_channel("multi_user", "telegram", "multi_user_2")
.await
.expect("bind_channel_2");
store
.update_channel_contact("telegram", "multi_user_1", "unique_multi_a")
.await
.expect("update_channel_contact_1");
store
.update_channel_contact("telegram", "multi_user_2", "unique_multi_b")
.await
.expect("update_channel_contact_2");
let msg = gui_msg("multi_user", "Hi both!");
super::mirror_gui_message_to_telegram(&msg).await;
let guard = sent.lock().unwrap_poison();
let our_msgs: Vec<_> = guard
.iter()
.filter(|m| m.recipient == "unique_multi_a" || m.recipient == "unique_multi_b")
.collect();
assert_eq!(our_msgs.len(), 2, "expected two messages (one per binding)");
for m in &our_msgs {
assert_eq!(m.content, "<blockquote>\nHi both!\n</blockquote>");
}
let recipients: Vec<&str> = our_msgs.iter().map(|m| m.recipient.as_str()).collect();
assert!(recipients.contains(&"unique_multi_a"));
assert!(recipients.contains(&"unique_multi_b"));
}
async fn assert_mirror_strips_markers(
user_name: &str,
reply_target: &str,
content: &str,
expected_quote: &str,
) {
let mirrored =
mirror_single_to_binding(user_name, reply_target, &gui_msg(user_name, content)).await;
assert_eq!(mirrored.content, expected_quote);
}
#[tokio::test]
async fn strips_media_markers_from_content() {
assert_mirror_strips_markers(
"strip_markers",
"unique_markers",
"Check this [IMAGE:/tmp/screenshot.png] and my [AUDIO:/tmp/recording.mp3]",
"<blockquote>\nCheck this and my\n</blockquote>",
)
.await;
assert_mirror_strips_markers(
"strip_markers",
"unique_lowercase",
"See [image:/tmp/photo.png] and hear [audio:/tmp/sound.mp3]",
"<blockquote>\nSee and hear\n</blockquote>",
)
.await;
assert_mirror_strips_markers(
"strip_markers",
"unique_unmappable",
"See [DOCX:/tmp/report.docx] here",
"<blockquote>\nSee [DOCX:/tmp/report.docx] here\n</blockquote>",
)
.await;
}
#[tokio::test]
async fn preserves_markdown_formatting_in_blockquote() {
let mirrored = mirror_single_to_binding(
"md_user",
"unique_md",
&gui_msg("md_user", "**bold** and `code` and *italic*"),
)
.await;
assert_eq!(
mirrored.content,
"<blockquote>\n**bold** and `code` and *italic*\n</blockquote>"
);
}
#[tokio::test]
async fn mirrors_reply_reference_header() {
let mut msg = gui_msg("ref_user", "hello");
msg.reply_reference = Some(crate::channels::ReplyReference {
author: "alice".to_string(),
snippet: "hi there".to_string(),
});
let mirrored = mirror_single_to_binding("ref_user", "unique_ref", &msg).await;
assert_eq!(
mirrored.content,
"<blockquote>\n↩ alice: hi there\nhello\n</blockquote>"
);
}
#[tokio::test]
async fn mirrors_media_only_with_reply_reference() {
let mut msg = gui_msg("media_ref", "[IMAGE:/tmp/screenshot.png]");
msg.reply_reference = Some(crate::channels::ReplyReference {
author: "bob".to_string(),
snippet: "[Photo]".to_string(),
});
let mirrored = mirror_single_to_binding("media_ref", "unique_media_ref", &msg).await;
assert_eq!(
mirrored.content,
"<blockquote>\n↩ bob: [Photo]\n</blockquote>"
);
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn user_command_entries_reflect_admin_state() {
let _lock = acquire_mirror_lock().await;
crate::users::test_util::init_test_store().await;
let store = crate::users::store();
let admin = crate::users::ADMIN_USER_NAME;
crate::users::test_util::with_test_workspaces(
&[
("/tmp/mahbot_test_ws_menu", "menu_ws"),
("/tmp/mahbot_test_ws_menu2", "menu_ws2"),
],
crate::users::test_util::with_admin_workspace_restored(async {
store
.set_selected_workspace(admin, Some("menu_ws"))
.await
.unwrap();
let admin_entries = user_command_entries(admin).await;
let cmds: Vec<&str> = admin_entries.iter().map(|(c, _)| c.as_str()).collect();
assert!(cmds.contains(&"board"));
assert!(cmds.contains(&"update"));
assert!(cmds.contains(&"pause"));
assert!(!cmds.contains(&"unpause"));
assert!(cmds.contains(&"maintenance_on"));
assert!(!cmds.contains(&"maintenance_off"));
for cmd in ["manager", "assistant", "engineer"] {
assert!(!cmds.contains(&cmd));
}
assert!(cmds.contains(&"image_models"));
assert!(cmds.contains(&"video_models"));
assert!(cmds.contains(&"workspace"));
assert_eq!(cmds.last(), Some(&"clear"));
let pos = |cmd: &str| cmds.iter().position(|c| *c == cmd).unwrap();
assert!(pos("workspace") < pos("board"));
assert!(pos("board") < pos("update"));
assert!(pos("update") < pos("image_models"));
assert!(pos("image_models") < pos("clear"));
crate::workspace::store()
.set_paused("menu_ws", true)
.await
.unwrap();
crate::workspace::store()
.set_maintenance_enabled("menu_ws", true)
.await
.unwrap();
let flipped = user_command_entries(admin).await;
let flipped_cmds: Vec<&str> = flipped.iter().map(|(c, _)| c.as_str()).collect();
assert!(flipped_cmds.contains(&"unpause"));
assert!(!flipped_cmds.contains(&"pause"));
assert!(flipped_cmds.contains(&"maintenance_off"));
assert!(!flipped_cmds.contains(&"maintenance_on"));
{
let _guard = crate::self_update::set_update_cache_for_test(false, false);
let hidden = user_command_entries(admin).await;
let hidden_cmds: Vec<&str> = hidden.iter().map(|(c, _)| c.as_str()).collect();
assert!(!hidden_cmds.contains(&"update"));
}
let bob = user_command_entries("bob").await;
let cmds: Vec<&str> = bob.iter().map(|(c, _)| c.as_str()).collect();
assert!(!cmds.contains(&"agents"));
assert!(!cmds.contains(&"board"));
assert!(!cmds.contains(&"workspace"));
assert!(!cmds.contains(&"update"));
assert!(!cmds.contains(&"pause"));
assert!(!cmds.contains(&"unpause"));
assert!(!cmds.contains(&"assistant"));
assert!(!cmds.contains(&"manager"));
assert!(cmds.contains(&"image_models"));
assert!(cmds.contains(&"video_models"));
assert_eq!(cmds[0], "image_models");
}),
)
.await;
}
#[test]
fn workspace_picker_marks_only_the_active_workspace() {
let workspaces = vec![
crate::Workspace {
name: "ws1".to_string(),
path: "/src/alpha_repo".to_string(),
status: crate::WorkspaceStatus::Ready,
..Default::default()
},
crate::Workspace {
name: "ws2".to_string(),
path: "/src/beta_repo".to_string(),
status: crate::WorkspaceStatus::Ready,
paused: true,
..Default::default()
},
crate::Workspace {
name: "ws3".to_string(),
path: "/src/gamma".to_string(),
status: crate::WorkspaceStatus::Failed,
..Default::default()
},
];
let keyboard = workspace_picker_keyboard(&workspaces, Some("ws2"));
let rows = keyboard["inline_keyboard"].as_array().expect("rows");
assert_eq!(rows.len(), 3, "one row per workspace");
let buttons: Vec<&serde_json::Value> = rows
.iter()
.map(|row| {
let row = row.as_array().expect("a row is a list of buttons");
assert_eq!(row.len(), 1, "one button per row");
&row[0]
})
.collect();
let payloads: Vec<&str> = buttons
.iter()
.map(|b| b["callback_data"].as_str().expect("callback_data"))
.collect();
assert_eq!(
payloads,
vec![
"__act__set_workspace|ws1",
"__act__set_workspace|ws2",
"__act__set_workspace|ws3",
]
);
let labels: Vec<&str> = buttons
.iter()
.map(|b| b["text"].as_str().expect("text"))
.collect();
assert_eq!(labels[0], "alpha_repo — ready");
assert_eq!(labels[1], "\u{2713} beta_repo — ready, paused");
assert_eq!(labels[2], "gamma — failed");
assert!(!labels[0].contains('\u{2713}'), "inactive: {}", labels[0]);
assert!(!labels[2].contains('\u{2713}'), "inactive: {}", labels[2]);
let unmarked = workspace_picker_keyboard(&workspaces, None);
for row in unmarked["inline_keyboard"].as_array().expect("rows") {
let label = row[0]["text"].as_str().expect("text");
assert!(
!label.contains('\u{2713}'),
"no workspace is active, so nothing is marked: {label}"
);
}
}
#[test]
fn workspace_picker_button_payload_fits_telegram_limit() {
const CALLBACK_DATA_MAX: usize = 64;
let name = "a".repeat(crate::workspace::MAX_NAME_LEN);
let ws = crate::Workspace {
name: name.clone(),
path: format!("/tmp/{name}"),
..Default::default()
};
let keyboard = workspace_picker_keyboard(&[ws], None);
let payload = keyboard["inline_keyboard"][0][0]["callback_data"]
.as_str()
.expect("callback_data");
assert_eq!(payload, format!("__act__set_workspace|{name}"));
assert!(
payload.len() <= CALLBACK_DATA_MAX,
"Telegram caps callback_data at 64 bytes, got {}",
payload.len()
);
}
#[test]
fn test_update_notification_texts_render_as_plain_single_messages() {
for text in [
crate::self_update::UPDATE_BUILD_COMPLETE_MSG,
crate::self_update::UPDATE_DOWNLOAD_COMPLETE_MSG,
crate::self_update::UPDATE_RESTART_MSG,
&crate::self_update::back_online_message(false),
&crate::self_update::back_online_message(true),
] {
assert_eq!(to_telegram_html(text), text, "not plain text: {text}");
assert_eq!(
split_message_for_telegram(text).len(),
1,
"split into several messages: {text}"
);
}
let failure = crate::self_update::update_failure_notification(&anyhow::anyhow!(
"Failed to build from source:\n```\nboom\n```"
));
let html = to_telegram_html(&failure);
assert_eq!(html.lines().next(), failure.lines().next());
assert_eq!(split_message_for_telegram(&html).len(), 1);
}