use super::*;
use strum::IntoEnumIterator;
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 >");
}
#[test]
fn board_listing_isolates_hostile_titles() {
assert_eq!(
format_board_line(
&crate::board::TicketPhase::InDevelopment,
"mahbot-123",
"Title",
),
"• **in development** `mahbot-123` Title"
);
let state = crate::board::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("• <b>"), "state formatting lost: {line:?}");
assert!(
line.contains("</b> <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 test_inline_formatting() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static str,
}
let cases = vec![
Case {
name: "bold double asterisk",
input: "**hello** world",
expected: "<b>hello</b> world",
},
Case {
name: "bold double underscore",
input: "__hello__ world",
expected: "<b>hello</b> world",
},
Case {
name: "italic",
input: "*hello* world",
expected: "<i>hello</i> world",
},
Case {
name: "inline code",
input: "use `hello()` in your code",
expected: "use <code>hello()</code> in your code",
},
Case {
name: "strikethrough",
input: "this is ~~wrong~~ fixed",
expected: "this is <s>wrong</s> fixed",
},
Case {
name: "combined",
input: "**bold** and *italic* and `code` and ~~strike~~",
expected: "<b>bold</b> and <i>italic</i> and <code>code</code> and <s>strike</s>",
},
Case {
name: "bold inside text",
input: "before **middle** after",
expected: "before <b>middle</b> after",
},
Case {
name: "escaping inner HTML",
input: "**a < b & c > d**",
expected: "<b>a < b & c > d</b>",
},
Case {
name: "unmatched double asterisk",
input: "hello ** world",
expected: "hello ** world",
},
Case {
name: "unmatched single asterisk",
input: "hello * world",
expected: "hello * world",
},
Case {
name: "triple asterisk",
input: "***",
expected: "***",
},
Case {
name: "unmatched backtick",
input: "hello ` world",
expected: "hello ` world",
},
Case {
name: "double backtick",
input: "hello `` world",
expected: "hello `` world",
},
Case {
name: "unmatched tilde",
input: "hello ~ world",
expected: "hello ~ world",
},
Case {
name: "bold and italic overlap",
input: "***bold**",
expected: "<b>*bold</b>",
},
];
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");
}
#[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");
std::fs::write(&png, b"fake-png").unwrap();
std::fs::write(&ogg, b"fake-ogg").unwrap();
std::fs::write(&vid, b"fake-mp4").unwrap();
let (cleaned, att) = parse_attachment_markers("use `[IMAGE:path]` or `[VIDEO:...]`");
assert_eq!(cleaned, "use `[IMAGE:path]` or `[VIDEO:...]`");
assert!(att.is_empty());
let (cleaned, att) = parse_attachment_markers(&format!("[IMAGE:{}]", dir.path().display()));
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()
));
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]");
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()));
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]");
assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
assert!(att.is_empty());
let (cleaned, att) = parse_attachment_markers(&format!("[image:{}]", png.display()));
assert_eq!(cleaned, "");
assert_eq!(att.len(), 1);
assert_eq!(att[0].kind, TelegramAttachmentKind::Image);
}
#[test]
fn parse_path_only_attachment_tests() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("snap.png");
std::fs::write(&p, b"fake-png").unwrap();
let parsed = parse_path_only_attachment(p.to_string_lossy().as_ref()).unwrap();
assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
assert_eq!(parsed.target, p.to_string_lossy());
assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
}
#[test]
fn infer_attachment_kind_from_target_detects_document_extension() {
assert_eq!(
infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
Some(TelegramAttachmentKind::Document)
);
}
#[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"));
}
#[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");
}
#[test]
fn test_extract_reply_context() {
let msg = serde_json::json!({
"reply_to_message": {
"from": { "username": "alice" },
"text": "Hello world"
}
});
let ctx = TelegramChannel::extract_reply_context(&msg).unwrap();
assert_eq!(ctx, "> @alice:\n> Hello world");
let msg = serde_json::json!({
"reply_to_message": {
"from": { "username": "bob" },
"voice": { "file_id": "abc", "duration": 5 }
}
});
let ctx = TelegramChannel::extract_reply_context(&msg).unwrap();
assert_eq!(ctx, "> @bob:\n> [Voice message]");
let msg = serde_json::json!({
"text": "just a regular message"
});
assert!(TelegramChannel::extract_reply_context(&msg).is_none());
let msg = serde_json::json!({
"reply_to_message": {
"from": { "id": 999, "first_name": "Charlie" },
"text": "Hi there"
}
});
let ctx = TelegramChannel::extract_reply_context(&msg).unwrap();
assert_eq!(ctx, "> Charlie:\n> Hi there");
}
#[tokio::test]
async fn parse_update_message_includes_reply_context() {
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!(
parsed.content.starts_with("> @bot:"),
"content should start with quote: {}",
parsed.content
);
assert!(
parsed.content.contains("translate this"),
"content should contain user text"
);
assert!(
parsed.content.contains("Bonjour le monde"),
"content should contain quoted text"
);
}
#[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::Audio);
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,
"photo.jpg",
std::path::Path::new("/tmp/workspace/photo.jpg"),
None,
);
assert_eq!(c, "[IMAGE:/tmp/workspace/photo.jpg]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
"report.pdf",
std::path::Path::new("/tmp/workspace/report.pdf"),
None,
);
assert_eq!(c, "[Document: report.pdf] /tmp/workspace/report.pdf");
assert!(!c.contains("[IMAGE:"));
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
"notes.md",
std::path::Path::new("/tmp/workspace/notes.md"),
None,
);
assert!(!c.contains("[IMAGE:"));
assert!(c.starts_with("[Document:"));
for (filename, path) in [
("file.md", "/tmp/workspace/file.md"),
("file.txt", "/tmp/workspace/file.txt"),
("file.pdf", "/tmp/workspace/file.pdf"),
("file.csv", "/tmp/workspace/file.csv"),
("file.json", "/tmp/workspace/file.json"),
("file.zip", "/tmp/workspace/file.zip"),
("file", "/tmp/workspace/file"),
] {
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
filename,
std::path::Path::new(path),
None,
);
assert!(
!c.contains("[IMAGE:"),
"{filename}: should not get [IMAGE:]"
);
assert!(
c.starts_with("[Document:"),
"{filename}: should use [Document:]"
);
}
for ext in ["png", "jpg", "jpeg", "webp"] {
let filename = format!("photo.{ext}");
let c = format_attachment_content(
IncomingAttachmentKind::Photo,
&filename,
std::path::Path::new(&format!("/tmp/workspace/{filename}")),
None,
);
assert!(c.starts_with("[IMAGE:"), "{ext}: should get [IMAGE:]");
}
let c = format_attachment_content(
IncomingAttachmentKind::Document,
"image.jpg",
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,
"image_no_ext",
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,
"anim_no_ext",
std::path::Path::new("/tmp/workspace/anim_no_ext"),
Some(mime),
);
assert!(!c.contains("[IMAGE:"), "{mime}: should not get [IMAGE:]");
assert!(
c.starts_with("[Document:"),
"{mime}: should use [Document:]"
);
}
let c = format_attachment_content(
IncomingAttachmentKind::Audio,
"voice.ogg",
std::path::Path::new("/tmp/workspace/voice.ogg"),
None,
);
assert_eq!(c, "[AUDIO:/tmp/workspace/voice.ogg]");
let c = format_attachment_content(
IncomingAttachmentKind::Audio,
"song.mp3",
std::path::Path::new("/tmp/workspace/song.mp3"),
Some("audio/mpeg"),
);
assert_eq!(c, "[AUDIO:/tmp/workspace/song.mp3]");
let c = format_attachment_content(
IncomingAttachmentKind::Video,
"clip.mp4",
std::path::Path::new("/tmp/workspace/clip.mp4"),
None,
);
assert_eq!(c, "[VIDEO:/tmp/workspace/clip.mp4]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
"clip.mp4",
std::path::Path::new("/tmp/workspace/clip.mp4"),
None,
);
assert_eq!(c, "[VIDEO:/tmp/workspace/clip.mp4]");
let c = format_attachment_content(
IncomingAttachmentKind::Document,
"clip_no_ext",
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,
"notes.txt",
std::path::Path::new("/tmp/workspace/notes.txt"),
None,
);
assert_eq!(c, "[Document: notes.txt] /tmp/workspace/notes.txt");
}
#[test]
fn attachment_multimodal_and_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)));
}
let content = format!(
"[IMAGE:{}]\n\nLook at this screenshot",
std::path::Path::new("/tmp/workspace/photo.jpg").display()
);
assert_eq!(
content,
"[IMAGE:/tmp/workspace/photo.jpg]\n\nLook at this screenshot"
);
}
#[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"
);
}
#[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
}
struct SpyChannel {
sent: Arc<Mutex<Vec<SendMessage>>>,
}
#[async_trait]
impl crate::Channel for SpyChannel {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent.lock().unwrap_poison().push(message.clone());
Ok(())
}
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
Ok(())
}
fn name(&self) -> &'static str {
"telegram"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
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 sent = Arc::new(Mutex::new(Vec::new()));
let registry = crate::CHANNEL_REGISTRY.get_or_init(crate::ChannelRegistry::default);
registry.register(Arc::new(SpyChannel {
sent: Arc::clone(&sent),
}) 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();
let all_roles = crate::Role::iter().collect::<Vec<_>>();
store
.add_user(user_name, Some("full"), &all_roles)
.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 gui_msg(user_name: &str, content: &str) -> ChannelMessage {
ChannelMessage {
user_name: user_name.to_string(),
reply_target: String::new(),
content: content.to_string(),
channel: "gui".to_string(),
workspace: "test".to_string(),
optimistic_id: None,
callback_query_id: None,
}
}
fn telegram_msg(user_name: &str, content: &str) -> ChannelMessage {
ChannelMessage {
user_name: user_name.to_string(),
reply_target: "chat:thread".to_string(),
content: content.to_string(),
channel: "telegram".to_string(),
workspace: "test".to_string(),
optimistic_id: None,
callback_query_id: None,
}
}
fn voice_msg(user_name: &str, content: &str) -> ChannelMessage {
ChannelMessage {
user_name: user_name.to_string(),
reply_target: String::new(),
content: content.to_string(),
channel: "voice".to_string(),
workspace: "test".to_string(),
optimistic_id: None,
callback_query_id: None,
}
}
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, None, &[])
.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, None, &[])
.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;
}
#[tokio::test]
async fn sends_blockquote_to_single_binding() {
let (sent, _lock) = setup_mirror_test_env().await;
setup_user_with_telegram_binding("single_user", "unique_single", "single_user").await;
let msg = gui_msg("single_user", "Hello, world!");
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_single")
.collect();
assert_eq!(our_msgs.len(), 1, "expected exactly one message");
assert_eq!(
our_msgs[0].content,
"<blockquote>\nHello, world!\n</blockquote>"
);
assert!(our_msgs[0].reply_markup.is_none());
}
#[tokio::test]
async fn mirrors_voice_transcript_to_telegram() {
let (sent, _lock) = setup_mirror_test_env().await;
setup_user_with_telegram_binding("voice_user", "unique_voice", "voice_user").await;
let msg = voice_msg("voice_user", "Record this voice note");
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_voice")
.collect();
assert_eq!(our_msgs.len(), 1, "expected exactly one message");
assert_eq!(
our_msgs[0].content,
"<blockquote>\nRecord this voice note\n</blockquote>"
);
assert!(our_msgs[0].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", None, &[])
.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 (sent, _lock) = setup_mirror_test_env().await;
setup_user_with_telegram_binding(user_name, reply_target, user_name).await;
let msg = gui_msg(user_name, content);
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);
assert_eq!(our_msgs[0].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;
}
#[tokio::test]
async fn strips_lowercase_media_markers_from_content() {
assert_mirror_strips_markers(
"lowercase_markers",
"unique_lowercase",
"See [image:/tmp/photo.png] and hear [audio:/tmp/sound.mp3]",
"<blockquote>\nSee and hear\n</blockquote>",
)
.await;
}
#[tokio::test]
async fn preserves_markdown_formatting_in_blockquote() {
let (sent, _lock) = setup_mirror_test_env().await;
setup_user_with_telegram_binding("md_user", "unique_md", "md_user").await;
let msg = gui_msg("md_user", "**bold** and `code` and *italic*");
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_md")
.collect();
assert_eq!(our_msgs.len(), 1);
assert_eq!(
our_msgs[0].content,
"<blockquote>\n**bold** and `code` and *italic*\n</blockquote>"
);
}
#[tokio::test]
async fn user_command_entries_reflect_role_and_admin() {
let _lock = acquire_mirror_lock().await;
crate::users::test_util::init_test_store().await;
let store = crate::users::store();
crate::util::test::create_test_workspace("/tmp/mahbot_test_ws_menu", "menu_ws").await;
store
.update_user(
"alice",
crate::users::FieldUpdate::Unchanged,
crate::users::FieldUpdate::Set("menu_ws"),
crate::users::FieldUpdate::Unchanged,
)
.await
.unwrap();
let alice = user_command_entries("alice").await;
let cmds: Vec<&str> = alice.iter().map(|(c, _)| c.as_str()).collect();
assert!(cmds.contains(&"board"));
assert!(cmds.contains(&"pause"));
assert!(!cmds.contains(&"unpause"));
assert!(cmds.contains(&"maintenance_on"));
assert!(!cmds.contains(&"maintenance_off"));
assert!(cmds.contains(&"engineer"));
assert!(cmds.contains(&"artist"));
assert!(cmds.contains(&"image_models"));
assert!(cmds.contains(&"video_models"));
assert_eq!(cmds[0], "manager");
assert_eq!(cmds.last(), Some(&"clear"));
let pos = |cmd: &str| cmds.iter().position(|c| *c == cmd).unwrap();
assert!(pos("manager") < pos("board"));
assert!(pos("board") < pos("image_models"));
assert!(pos("image_models") < pos("clear"));
let manager_desc = alice
.iter()
.find(|(c, _)| c == "manager")
.map(|(_, d)| d.as_str())
.unwrap();
assert!(manager_desc.contains("current"));
store
.update_user(
"alice",
crate::users::FieldUpdate::Set("artist"),
crate::users::FieldUpdate::Unchanged,
crate::users::FieldUpdate::Unchanged,
)
.await
.unwrap();
let entries = user_command_entries("alice").await;
let artist_desc = entries
.iter()
.find(|(c, _)| c == "artist")
.map(|(_, d)| d.as_str())
.unwrap();
assert!(artist_desc.contains("current"));
let analyst_desc = entries
.iter()
.find(|(c, _)| c == "analyst")
.map(|(_, d)| d.as_str())
.unwrap();
assert!(!analyst_desc.contains("current"));
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("alice").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 bob = user_command_entries("bob").await;
let cmds: Vec<&str> = bob.iter().map(|(c, _)| c.as_str()).collect();
assert!(!cmds.contains(&"board"));
assert!(!cmds.contains(&"pause"));
assert!(!cmds.contains(&"unpause"));
assert!(cmds.contains(&"engineer"));
}