road-runner-common 0.22.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! What a service has to write to add a notification, exercised through the public API.

use road_runner_common::notification::{
    workflows, NotificationEvent, Recipient, Severity, TriggerRequest,
};

/// The shape a service is expected to produce: plain data, one workflow, one payload.
struct FiatWithdrawalRefunded {
    amount: String,
    currency: String,
    request_id: String,
}

impl NotificationEvent for FiatWithdrawalRefunded {
    fn workflow(&self) -> &str {
        workflows::FIAT_WITHDRAWAL_FAILED_REFUNDED
    }

    fn payload(&self) -> serde_json::Value {
        serde_json::json!({
            "amount": self.amount,
            "currency": self.currency,
            "requestId": self.request_id,
        })
    }

    fn severity(&self) -> Severity {
        Severity::High
    }

    fn transaction_id(&self) -> Option<String> {
        Some(format!("fiat-withdraw-refund:{}", self.request_id))
    }
}

fn event() -> FiatWithdrawalRefunded {
    FiatWithdrawalRefunded {
        amount: "1500.00".into(),
        currency: "TRY".into(),
        request_id: "0192f3ab-cdef-7000-8000-000000000001".into(),
    }
}

#[test]
fn an_event_binds_its_payload_to_a_registered_workflow() {
    let event = event();
    assert_eq!(event.workflow(), "Fiat_Withdrawal_Failed_Refunded");

    let payload = event.payload();
    assert_eq!(payload["amount"], "1500.00");
    assert_eq!(payload["currency"], "TRY");
    assert_eq!(payload["requestId"], "0192f3ab-cdef-7000-8000-000000000001");
}

/// The dedup key is derived from the business fact, so a replayed producer cannot
/// notify the same user twice for the same refund.
#[test]
fn the_transaction_id_is_stable_for_the_same_fact() {
    assert_eq!(event().transaction_id(), event().transaction_id());
    assert_eq!(
        event().transaction_id().unwrap(),
        "fiat-withdraw-refund:0192f3ab-cdef-7000-8000-000000000001"
    );
}

/// A minimal event only has to answer two questions; the rest has defaults.
#[test]
fn severity_and_transaction_id_are_optional() {
    struct Minimal;
    impl NotificationEvent for Minimal {
        fn workflow(&self) -> &str {
            "some-workflow"
        }
        fn payload(&self) -> serde_json::Value {
            serde_json::json!({})
        }
    }

    assert_eq!(Minimal.severity(), Severity::Normal);
    assert_eq!(Minimal.transaction_id(), None);
}

#[test]
fn an_events_severity_reaches_the_wire() {
    let event = event();
    let request = TriggerRequest::new(
        event.workflow(),
        Recipient::new("sub-1", "tr"),
        event.payload(),
    )
    .with_severity(event.severity());

    let value = serde_json::to_value(&request).unwrap();
    assert_eq!(value["overrides"]["severity"], "high");
}

/// A topic target must serialize to the shape the notification center validates
/// (`TopicPayloadDto`), and a subscriber target must be unchanged by its existence.
#[test]
fn a_topic_target_serializes_to_the_notification_centers_shape() {
    use road_runner_common::notification::{TopicRecipient, TriggerRequest};

    let value = serde_json::to_value(TriggerRequest::new(
        workflows::ADMIN_FIAT_UNMATCHED_DEPOSIT,
        TopicRecipient::new("topic-1"),
        serde_json::json!({ "amount": "500.00" }),
    ))
    .unwrap();

    assert_eq!(value["name"], "Admin_Fiat_Unmatched_Deposit");
    assert_eq!(value["to"]["type"], "Topic");
    assert_eq!(value["to"]["topicKey"], "topic-1");
    // A topic addresses a list, not a person — no subscriber key rides along.
    assert!(value["to"].get("subscriberId").is_none());
}

/// The untagged enum must not have changed how a person is addressed.
#[test]
fn a_subscriber_target_is_unchanged_by_topics_existing() {
    let value = serde_json::to_value(TriggerRequest::new(
        "some-workflow",
        Recipient::new("sub-1", "tr"),
        serde_json::json!({}),
    ))
    .unwrap();

    assert_eq!(value["to"]["subscriberId"], "sub-1");
    assert_eq!(value["to"]["locale"], "tr");
    assert!(value["to"].get("type").is_none());
}