road-runner-common 0.22.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Audience resolution is a seam, not a hard dependency: a service can supply its own
//! resolver, and a failed lookup is reported as such instead of being mistaken for a
//! delivery failure.

use std::sync::Arc;

use async_trait::async_trait;
use road_runner_common::notification::{
    Audience, AudienceError, AudienceResolver, NotificationEvent, NotifyError, Notifier,
    RecipientRef,
};

struct Ping;

impl NotificationEvent for Ping {
    fn workflow(&self) -> &str {
        "ping"
    }
    fn payload(&self) -> serde_json::Value {
        serde_json::json!({})
    }
}

/// A service-supplied resolver — the point of the trait.
struct Unavailable;

#[async_trait]
impl AudienceResolver for Unavailable {
    async fn resolve(&self, reference: &RecipientRef) -> Result<Audience, AudienceError> {
        Err(AudienceError::Unavailable {
            reference: reference.log_label(),
            message: "cex-account is down".into(),
        })
    }
}

/// An unresolvable audience must not reach the HTTP layer at all — there is no one to
/// send to, and the caller should see that rather than a transport error.
#[tokio::test]
async fn an_unresolvable_audience_fails_before_any_trigger() {
    let notifier = Notifier::new(
        "http://notification.invalid/admin/api/v1/events/trigger",
        Arc::new(Unavailable),
    );

    let error = notifier
        .notify(&RecipientRef::AccountId(42), &Ping)
        .await
        .expect_err("resolution failed, so the trigger cannot proceed");

    match error {
        NotifyError::Audience(AudienceError::Unavailable { reference, .. }) => {
            assert_eq!(reference, "accountId=42");
        }
        other => panic!("expected an audience error, got {other:?}"),
    }
}

/// The best-effort variant swallows exactly the same failure, because the operation it
/// reports on has already succeeded.
#[tokio::test]
async fn best_effort_delivery_swallows_a_resolution_failure() {
    let notifier = Notifier::new(
        "http://notification.invalid/admin/api/v1/events/trigger",
        Arc::new(Unavailable),
    );

    notifier
        .notify_best_effort(&RecipientRef::AccountId(42), &Ping)
        .await;
}

/// An internal channel resolves from configuration, never from cex-account: an alert
/// about an unmatched deposit must go out even when the directory is unreachable.
#[tokio::test]
async fn an_admin_channel_never_touches_the_audience_resolver() {
    use road_runner_common::notification::{AdminChannel, AdminTopics};

    let notifier = Notifier::new(
        "http://127.0.0.1:1/admin/api/v1/events/trigger",
        Arc::new(Unavailable),
    )
    .with_admin_topics(AdminTopics::new("topic-1", "topic-2"));

    // The resolver would fail; reaching the transport instead proves it was bypassed.
    let error = notifier
        .notify(&RecipientRef::Admin(AdminChannel::Alert), &Ping)
        .await
        .expect_err("nothing is listening on port 1");

    assert!(
        matches!(error, NotifyError::Trigger(_)),
        "expected a transport error, got {error:?}"
    );
}