opencrabs 0.5.2

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
Documentation
//! Unit tests for create_topic and rename_topic actions in telegram_send (#161).

use crate::brain::tools::Tool;
use crate::brain::tools::ToolExecutionContext;
use crate::brain::tools::telegram_send::TelegramSendTool;
use crate::channels::telegram::TelegramState;
use crate::db::ChannelMessageRepository;
use crate::db::Database;
use serde_json::json;
use std::sync::Arc;
use teloxide::Bot;
use uuid::Uuid;

fn make_tool() -> TelegramSendTool {
    let state = Arc::new(TelegramState::new());
    TelegramSendTool::new(state)
}

/// `execute()` refuses a botless state before any action dispatch ("Telegram
/// is not connected"), so tests that drive the full tool must install a dummy
/// bot first. The name-bounds validations return before any request is built —
/// the bot is never used on the wire.
async fn make_connected_tool() -> TelegramSendTool {
    let state = Arc::new(TelegramState::new());
    state.set_bot(Bot::new("TESTTOKEN")).await;
    TelegramSendTool::new(state)
}

#[test]
fn telegram_send_schema_declares_topic_actions_and_params() {
    let tool = make_tool();
    let schema = tool.input_schema();
    let props = schema.get("properties").expect("schema has properties");

    let action = props.get("action").expect("schema has action");
    let enums = action
        .get("enum")
        .and_then(|v| v.as_array())
        .expect("action has enum");
    let enum_strs: Vec<&str> = enums.iter().filter_map(|v| v.as_str()).collect();
    assert!(
        enum_strs.contains(&"create_topic"),
        "action enum missing create_topic"
    );
    assert!(
        enum_strs.contains(&"rename_topic"),
        "action enum missing rename_topic"
    );
    assert!(
        enum_strs.contains(&"bind_topic"),
        "action enum missing bind_topic"
    );
    assert_eq!(
        enum_strs.len(),
        23,
        "action enum should contain exactly 23 actions"
    );

    let name = props.get("name").expect("schema has name property");
    assert_eq!(name.get("type").and_then(|v| v.as_str()), Some("string"));
}

#[tokio::test]
async fn telegram_send_rejects_empty_or_too_long_topic_name() {
    let tool = make_connected_tool().await;
    let ctx = ToolExecutionContext::new(Uuid::new_v4());

    // Empty name
    let res = tool
        .execute(
            json!({
                "action": "create_topic",
                "chat_id": -1001234567890i64,
                "name": "   "
            }),
            &ctx,
        )
        .await
        .expect("tool execution returns Ok(ToolResult)");
    assert!(!res.success, "empty topic name should fail validation");
    assert!(
        res.error
            .unwrap_or_default()
            .contains("between 1 and 128 characters")
    );

    // Name exceeding 128 chars
    let long_name = "a".repeat(129);
    let res2 = tool
        .execute(
            json!({
                "action": "create_topic",
                "chat_id": -1001234567890i64,
                "name": long_name
            }),
            &ctx,
        )
        .await
        .expect("tool execution returns Ok(ToolResult)");
    assert!(!res2.success, "overlong topic name should fail validation");
    assert!(
        res2.error
            .unwrap_or_default()
            .contains("between 1 and 128 characters")
    );

    // Rename without thread_id
    let res3 = tool
        .execute(
            json!({
                "action": "rename_topic",
                "chat_id": -1001234567890i64,
                "name": "Valid Name"
            }),
            &ctx,
        )
        .await
        .expect("tool execution returns Ok(ToolResult)");
    assert!(!res3.success, "rename without thread_id should fail");
    assert!(res3.error.unwrap_or_default().contains("thread_id"));
}

#[tokio::test]
async fn record_topic_created_and_edited_persists_to_repository() {
    let db = Database::connect_in_memory().await.expect("in-memory db");
    db.run_migrations().await.expect("migrations");
    let pool = db.pool().clone();

    // Simulate record_topic_created for a new topic
    crate::channels::telegram::record_topic_created(
        Some(pool.clone()),
        -1001234567890,
        42,
        "General Discussion",
        false,
    )
    .await;

    let repo = ChannelMessageRepository::new(pool.clone());
    let topics = repo
        .topics_for_chat("telegram", "-1001234567890")
        .await
        .expect("topics query");
    assert_eq!(topics.len(), 1);
    assert_eq!(topics[0].thread_id, "42");
    assert_eq!(topics[0].topic_name.as_deref(), Some("General Discussion"));

    // Simulate record_topic_created for a renamed topic
    crate::channels::telegram::record_topic_created(
        Some(pool.clone()),
        -1001234567890,
        42,
        "Announcements",
        true,
    )
    .await;

    let topics_after = repo
        .topics_for_chat("telegram", "-1001234567890")
        .await
        .expect("topics query");
    assert_eq!(topics_after.len(), 1);
    assert_eq!(topics_after[0].thread_id, "42");
    assert_eq!(topics_after[0].topic_name.as_deref(), Some("Announcements"));
}

#[tokio::test]
async fn telegram_send_bind_topic_persists_binding_and_records_thread_evidence() {
    let db = Database::connect_in_memory().await.expect("in-memory db");
    db.run_migrations().await.expect("migrations");
    let pool = db.pool().clone();

    let session_repo = crate::db::SessionRepository::new(pool.clone());
    let session = crate::db::Session::new(None, None, None);
    session_repo.create(&session).await.expect("create session");
    let session_id = session.id;

    let state = Arc::new(TelegramState::new());
    state.set_bot(Bot::new("TESTTOKEN")).await;
    let binding_repo = crate::db::SessionBindingRepository::new(pool.clone());
    state.set_binding_store(binding_repo.clone()).await;

    let tool = TelegramSendTool::new(state.clone());
    let ctx = ToolExecutionContext::new(session_id);

    let res = tool
        .execute(
            json!({
                "action": "bind_topic",
                "chat_id": -1001234567890i64,
                "thread_id": 42
            }),
            &ctx,
        )
        .await
        .expect("tool execution returns Ok(ToolResult)");

    assert!(res.success, "bind_topic should succeed: {:?}", res.error);
    let output: serde_json::Value = serde_json::from_str(&res.output).expect("valid json output");
    assert_eq!(
        output.get("status").and_then(|v| v.as_str()),
        Some("success")
    );
    assert_eq!(output.get("thread_id").and_then(|v| v.as_i64()), Some(42));

    // Verify session mapping in state
    let target = state.session_binding(session_id).await;
    assert_eq!(target, Some((-1001234567890i64, Some(42))));

    // Direct SQLite table checks to pinpoint any issue
    let (s_cnt, b_cnt): (i64, i64) = pool
        .get()
        .await
        .unwrap()
        .interact(|conn| {
            let s: i64 = conn
                .query_row("SELECT count(*) FROM sessions", [], |r| r.get(0))
                .unwrap();
            let b: i64 = conn
                .query_row("SELECT count(*) FROM session_bindings", [], |r| r.get(0))
                .unwrap();
            (s, b)
        })
        .await
        .unwrap();
    assert_eq!(s_cnt, 1, "sessions table must have 1 row");
    assert_eq!(b_cnt, 1, "session_bindings table must have 1 row");

    // Verify persistent binding in DB
    let bound = binding_repo
        .all_for_channel("telegram")
        .await
        .expect("query bindings");
    assert_eq!(bound.len(), 1);
    assert_eq!(bound[0].session_id, session_id.to_string());
    assert_eq!(bound[0].chat_id, "-1001234567890");
    assert_eq!(bound[0].thread_id, Some(42));
}