gsm-core 0.4.45

Core types and platform abstractions for the Greentic messaging runtime.
Documentation
use gsm_core::{
    CardAction, CardBlock, ChannelMessage, MessageCard, MessageEnvelope, OutKind, OutMessage,
    OutboundEnvelope, Platform,
};
use serde_json::json;

#[test]
fn channel_message_roundtrips_via_json() {
    let message = ChannelMessage {
        tenant: gsm_core::make_tenant_ctx(
            "acme".into(),
            Some("team-1".into()),
            Some("user-9".into()),
        ),
        channel_id: "webchat".into(),
        session_id: "sess-123".into(),
        route: Some("default".into()),
        payload: json!({"msg_id": "m-1", "text": "hello"}),
    };

    assert_roundtrip(&message);
}

#[test]
fn message_envelope_roundtrips() {
    let mut env = MessageEnvelope {
        tenant: "acme".into(),
        platform: Platform::Slack,
        chat_id: "room-42".into(),
        user_id: "user-7".into(),
        thread_id: Some("thread-9".into()),
        msg_id: "msg-9".into(),
        text: Some("hi".into()),
        timestamp: "2024-01-01T00:00:00Z".into(),
        context: Default::default(),
    };
    env.context.insert("locale".into(), json!("en-US"));

    assert_roundtrip(&env);
}

#[test]
fn out_message_roundtrips_with_card() {
    let ctx = gsm_core::make_tenant_ctx("acme".into(), None, Some("user-1".into()));
    let card = MessageCard {
        title: Some("Weather".into()),
        body: vec![CardBlock::Text {
            text: "Sunny".into(),
            markdown: false,
        }],
        actions: vec![CardAction::Postback {
            title: "Ack".into(),
            data: json!({"ok": true}),
        }],
    };
    let out = OutMessage {
        ctx,
        tenant: "acme".into(),
        platform: Platform::Teams,
        chat_id: "chat-1".into(),
        thread_id: Some("thread-2".into()),
        kind: OutKind::Card,
        text: None,
        message_card: Some(card),
        #[cfg(feature = "adaptive-cards")]
        adaptive_card: None,
        meta: Default::default(),
    };

    assert_roundtrip(&out);
}

#[test]
fn outbound_envelope_preserves_session_and_channel() {
    let channel = ChannelMessage {
        tenant: gsm_core::make_tenant_ctx("acme".into(), None, Some("user-1".into())),
        channel_id: "webchat".into(),
        session_id: "sess-55".into(),
        route: None,
        payload: json!({"text": "hello"}),
    };
    let outbound = OutboundEnvelope::for_channel(&channel, json!({"reply": "hi"}));
    assert_eq!(outbound.session_id, "sess-55");
    assert_eq!(outbound.channel_id, "webchat");
}

#[test]
fn channel_message_requires_session_id() {
    let message = ChannelMessage {
        tenant: gsm_core::make_tenant_ctx("acme".into(), None, Some("user-1".into())),
        channel_id: "webchat".into(),
        session_id: "sess-1".into(),
        route: None,
        payload: json!({"msg_id": "1"}),
    };
    let mut value = serde_json::to_value(message).expect("serialize");
    value.as_object_mut().expect("object").remove("session_id");

    let result = serde_json::from_value::<ChannelMessage>(value);
    assert!(
        result.is_err(),
        "missing session_id should fail to deserialize"
    );
}

#[tokio::test]
async fn correlation_falls_back_to_message_id() {
    use gsm_core::{
        InMemoryWorkerClient, WorkerMessage, WorkerRoutingConfig, empty_worker_response_for,
        forward_to_worker,
    };

    let channel = ChannelMessage {
        tenant: gsm_core::make_tenant_ctx("acme".into(), None, Some("user-9".into())),
        channel_id: "webchat".into(),
        session_id: "sess-999".into(),
        route: None,
        payload: json!({"msg_id": "msg-abc"}),
    };
    let config = WorkerRoutingConfig::default();
    let payload = json!({"kind": "noop"});
    let client = InMemoryWorkerClient::new(|req| {
        assert_eq!(req.correlation_id.as_deref(), Some("msg-abc"));
        let mut resp = empty_worker_response_for(&req);
        resp.messages = vec![WorkerMessage {
            kind: "text".into(),
            payload_json: serde_json::to_string(&json!({"ok": true})).unwrap(),
        }];
        resp
    });

    let outbound = forward_to_worker(&client, &channel, payload, &config, None)
        .await
        .expect("forward worker");
    assert_eq!(outbound.len(), 1);
    assert_eq!(outbound[0].meta["correlation_id"], "msg-abc");
}

#[test]
fn webchat_error_classification_is_stable() {
    use axum::response::IntoResponse;
    use gsm_core::platforms::webchat::{DirectLineError, WebChatError};
    use reqwest::StatusCode;
    use std::time::Duration;

    let err = WebChatError::DirectLine(DirectLineError::Remote {
        status: StatusCode::TOO_MANY_REQUESTS,
        retry_after: Some(Duration::from_secs(5)),
        message: "rate limited".into(),
    });
    let status = err.status();
    assert_eq!(status, StatusCode::BAD_REQUEST);

    let response = err.into_response();
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let retry_after = response
        .headers()
        .get(axum::http::header::RETRY_AFTER)
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);
    assert_eq!(retry_after.as_deref(), Some("5"));
}

fn assert_roundtrip<T>(value: &T)
where
    T: serde::Serialize + for<'de> serde::Deserialize<'de>,
{
    let json = serde_json::to_value(value).expect("serialize");
    let restored: T = serde_json::from_value(json.clone()).expect("deserialize");
    let json_restored = serde_json::to_value(restored).expect("serialize restored");
    assert_eq!(json, json_restored);
}