code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -1,10 +1,11 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
async-nats = "0.48.0"
config = { workspace = true }
async-nats = { workspace = true }
domain = { workspace = true }
event-payload = { workspace = true }
@@ -12,7 +13,7 @@ async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
chrono = { workspace = true, features = ["serde"] }
tokio = { workspace = true, features = ["rt", "sync"] }
futures = { workspace = true }

View File

@@ -11,48 +11,32 @@ pub struct NatsConfig {
pub subject_prefix: String,
pub stream_name: String,
pub consumer_name: String,
pub max_deliver: i64,
}
impl NatsConfig {
pub fn from_env() -> anyhow::Result<Self> {
Self::from_vars(
std::env::var("NATS_URL").ok().as_deref(),
std::env::var("NATS_MODE").ok().as_deref(),
std::env::var("NATS_SUBJECT_PREFIX").ok().as_deref(),
std::env::var("NATS_STREAM_NAME").ok().as_deref(),
std::env::var("NATS_CONSUMER_NAME").ok().as_deref(),
)
}
pub(crate) fn from_vars(
url: Option<&str>,
mode: Option<&str>,
subject_prefix: Option<&str>,
stream_name: Option<&str>,
consumer_name: Option<&str>,
pub fn from_config(
config: &::config::NatsConfig,
events: &::config::EventsConfig,
) -> anyhow::Result<Self> {
let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
let url = config
.url
.clone()
.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
let mode = match mode.unwrap_or("jetstream") {
let mode = match config.mode.as_str() {
"core" => NatsMode::Core,
"jetstream" => NatsMode::JetStream,
other => anyhow::bail!("unknown NATS_MODE: {other}"),
};
let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string();
let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string();
let consumer_name = consumer_name.unwrap_or("worker").to_string();
Ok(Self {
url: url.to_string(),
url,
mode,
subject_prefix,
stream_name,
consumer_name,
subject_prefix: config.subject_prefix.clone(),
stream_name: config.stream_name.clone(),
consumer_name: config.consumer_name.clone(),
max_deliver: i64::from(events.queue_max_attempts),
})
}
}
#[cfg(test)]
#[path = "tests/config.rs"]
mod tests;

View File

@@ -5,7 +5,7 @@ use async_nats::{
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope},
events::{AckHandle, DomainEvent, EventEnvelope, EventId, retry_backoff},
ports::EventConsumer,
};
use futures::{
@@ -15,12 +15,26 @@ use futures::{
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use crate::{config::NatsConfig, payload::NatsEventPayload, subject::consumer_subject_filter};
use crate::{config::NatsConfig, payload::NatsEventMessage, subject::consumer_subject_filter};
// ── JetStream ack handle ─────────────────────────────────────────────────────
struct NatsJetStreamAckHandle {
message: async_nats::jetstream::Message,
event_id: Option<EventId>,
max_deliver: i64,
}
struct NakPlan {
delay: std::time::Duration,
exhausted: bool,
}
fn nak_plan(delivered: i64, max_deliver: i64) -> NakPlan {
NakPlan {
delay: retry_backoff(u32::try_from(delivered.max(0)).unwrap_or(u32::MAX)),
exhausted: max_deliver > 0 && delivered >= max_deliver,
}
}
#[async_trait]
@@ -38,13 +52,36 @@ impl AckHandle for NatsJetStreamAckHandle {
}
async fn nack(&self) -> Result<(), DomainError> {
tracing::debug!(
"negatively acknowledging message with sequence {}",
self.message.info().unwrap().stream_sequence
);
let info = self
.message
.info()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let plan = nak_plan(info.delivered, self.max_deliver);
if plan.exhausted {
tracing::error!(
event_id = ?self.event_id.map(|id| id.value()),
sequence = info.stream_sequence,
delivered = info.delivered,
"event exhausted max deliveries; it remains in the stream — see JetStream max-deliveries advisory"
);
} else {
tracing::debug!(
sequence = info.stream_sequence,
delivered = info.delivered,
"nak with backoff {:?}",
plan.delay
);
}
self.message
.ack_with(AckKind::Nak(None))
.ack_with(AckKind::Nak(Some(plan.delay)))
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
self.message
.ack_with(AckKind::Nak(Some(delay)))
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
@@ -62,25 +99,47 @@ impl AckHandle for NoopAck {
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> {
Ok(())
}
}
// ── Envelope construction helpers ────────────────────────────────────────────
fn decode_js(msg: async_nats::jetstream::Message) -> Result<EventEnvelope, DomainError> {
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
fn decode_message(bytes: &[u8]) -> Result<(Option<EventId>, DomainEvent), DomainError> {
let message: NatsEventMessage = serde_json::from_slice(bytes)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(payload)?;
let id = message
.event_id
.as_deref()
.map(|s| {
uuid::Uuid::parse_str(s)
.map(EventId::from_uuid)
.map_err(|e| DomainError::InfrastructureError(format!("event_id: {e}")))
})
.transpose()?;
Ok((id, DomainEvent::try_from(message.payload)?))
}
fn decode_js(
msg: async_nats::jetstream::Message,
max_deliver: i64,
) -> Result<EventEnvelope, DomainError> {
let (id, event) = decode_message(&msg.payload)?;
Ok(EventEnvelope::new(
id,
event,
Box::new(NatsJetStreamAckHandle { message: msg }),
Box::new(NatsJetStreamAckHandle {
message: msg,
event_id: id,
max_deliver,
}),
))
}
fn decode_core(msg: async_nats::Message) -> Result<EventEnvelope, DomainError> {
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(payload)?;
Ok(EventEnvelope::new(event, Box::new(NoopAck)))
let (id, event) = decode_message(&msg.payload)?;
Ok(EventEnvelope::new(id, event, Box::new(NoopAck)))
}
// ── Channel-bridge shared by both consumers ──────────────────────────────────
@@ -94,6 +153,31 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, D
}))
}
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
duplicate_window: DEDUP_WINDOW,
..existing.clone()
})
}
fn consumer_config_update(
existing: &jetstream::consumer::Config,
desired_max_deliver: i64,
) -> Option<jetstream::consumer::Config> {
if existing.max_deliver == desired_max_deliver && existing.ack_wait == ACK_WAIT {
return None;
}
Some(jetstream::consumer::Config {
ack_wait: ACK_WAIT,
max_deliver: desired_max_deliver,
..existing.clone()
})
}
// ── JetStream consumer ────────────────────────────────────────────────────────
pub struct NatsJetStreamConsumer {
@@ -109,9 +193,25 @@ impl NatsJetStreamConsumer {
name: cfg.stream_name.clone(),
subjects: vec![consumer_subject_filter(&cfg.subject_prefix)],
max_messages: 100_000,
duplicate_window: DEDUP_WINDOW,
..Default::default()
})
.await?;
if let Some(updated) = stream_config_update(&stream.cached_info().config) {
tracing::warn!(
stream = %cfg.stream_name,
"existing JetStream stream has a different duplicate window; updating to {:?}",
DEDUP_WINDOW
);
if let Err(e) = js.update_stream(updated).await {
tracing::warn!(
"could not update stream {}: {e}; continuing. Fix manually: nats stream edit {} --dupe-window {}s",
cfg.stream_name,
cfg.stream_name,
DEDUP_WINDOW.as_secs()
);
}
}
let subject_filter = consumer_subject_filter(&cfg.subject_prefix);
let consumer = stream
@@ -120,14 +220,34 @@ impl NatsJetStreamConsumer {
pull::Config {
durable_name: Some(cfg.consumer_name.clone()),
filter_subject: subject_filter,
ack_wait: std::time::Duration::from_secs(600),
ack_wait: ACK_WAIT,
max_deliver: cfg.max_deliver,
..Default::default()
},
)
.await?;
if let Some(updated) =
consumer_config_update(&consumer.cached_info().config, cfg.max_deliver)
{
tracing::warn!(
consumer = %cfg.consumer_name,
"existing JetStream consumer has different max_deliver/ack_wait; updating to max_deliver={}",
cfg.max_deliver
);
if let Err(e) = stream.update_consumer(updated).await {
tracing::warn!(
"could not update consumer {}: {e}; continuing. Fix manually: nats consumer edit {} {} --max-deliver {}",
cfg.consumer_name,
cfg.stream_name,
cfg.consumer_name,
cfg.max_deliver
);
}
}
let (tx, rx) = mpsc::channel(128);
let max_deliver = cfg.max_deliver;
tokio::spawn(async move {
loop {
let mut messages = match consumer.messages().await {
@@ -144,7 +264,7 @@ impl NatsJetStreamConsumer {
while let Some(result) = messages.next().await {
let envelope = result
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
.and_then(decode_js);
.and_then(|m| decode_js(m, max_deliver));
if tx.send(envelope).await.is_err() {
tracing::info!("consumer channel closed, stopping message processing");
@@ -211,3 +331,7 @@ fn _assert_send_sync() {
check::<NatsJetStreamConsumer>();
check::<NatsCoreConsumer>();
}
#[cfg(test)]
#[path = "tests/consumer.rs"]
mod tests;

View File

@@ -1,52 +1,38 @@
mod config;
mod consumer;
mod payload;
mod publisher;
mod relay;
mod subject;
pub use config::{NatsConfig, NatsMode};
pub use consumer::{NatsCoreConsumer, NatsJetStreamConsumer};
pub use publisher::NatsEventPublisher;
pub use relay::NatsRelaySink;
use std::sync::Arc;
use domain::ports::{EventConsumer, EventPublisher};
use domain::ports::{EventConsumer, EventRelaySink};
pub async fn create_publisher(cfg: NatsConfig) -> anyhow::Result<Arc<dyn EventPublisher>> {
let client = async_nats::connect(&cfg.url).await?;
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(client, cfg.subject_prefix)),
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
client,
cfg.subject_prefix,
)),
};
tracing::info!("NATS publisher created (mode: {:?})", cfg.mode);
Ok(publisher)
}
pub async fn create_channel(
pub async fn create_bus(
cfg: NatsConfig,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
let client = async_nats::connect(&cfg.url).await?;
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(
client.clone(),
cfg.subject_prefix.clone(),
)),
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
client.clone(),
cfg.subject_prefix.clone(),
)),
let (consumer, sink): (Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>) = match cfg.mode {
NatsMode::Core => {
tracing::info!(
"core NATS mode: no broker-side dedup or redelivery; relying on consumer ledger"
);
(
Arc::new(NatsCoreConsumer::create(&cfg, client.clone()).await?),
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
)
}
NatsMode::JetStream => (
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),
),
};
let consumer: Arc<dyn EventConsumer> = match cfg.mode {
NatsMode::Core => Arc::new(NatsCoreConsumer::create(&cfg, client).await?),
NatsMode::JetStream => Arc::new(NatsJetStreamConsumer::create(&cfg, client).await?),
};
tracing::info!("NATS channel created (mode: {:?})", cfg.mode);
Ok((publisher, consumer))
tracing::info!("NATS bus created (mode: {:?})", cfg.mode);
Ok((consumer, sink))
}

View File

@@ -1 +1 @@
pub use event_payload::EventPayload as NatsEventPayload;
pub use event_payload::EventMessage as NatsEventMessage;

View File

@@ -1,60 +0,0 @@
use async_nats::{Client, jetstream};
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use crate::{payload::NatsEventPayload, subject::event_to_subject};
enum PublisherMode {
Core(Client),
JetStream(jetstream::Context),
}
pub struct NatsEventPublisher {
mode: PublisherMode,
subject_prefix: String,
}
impl NatsEventPublisher {
pub fn new_core(client: Client, subject_prefix: String) -> Self {
Self {
mode: PublisherMode::Core(client),
subject_prefix,
}
}
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
Self {
mode: PublisherMode::JetStream(jetstream::new(client)),
subject_prefix,
}
}
}
#[async_trait]
impl EventPublisher for NatsEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let subject = event_to_subject(&self.subject_prefix, event);
let payload = serde_json::to_vec(&NatsEventPayload::from(event))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match &self.mode {
PublisherMode::Core(client) => client
.publish(subject, payload.into())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
PublisherMode::JetStream(js) => js
.publish(subject, payload.into())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.await
.map(|_| ())
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
}
}
}
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<NatsEventPublisher>();
}

View File

@@ -0,0 +1,98 @@
use async_nats::{Client, HeaderMap, header::NATS_MESSAGE_ID, jetstream};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventId},
ports::EventRelaySink,
};
use event_payload::EventPayload;
use crate::{payload::NatsEventMessage, subject::event_to_subject};
pub(crate) struct RelayMessage {
pub subject: String,
pub payload: Vec<u8>,
pub msg_id: Option<String>,
}
pub(crate) fn relay_message(
prefix: &str,
id: Option<&EventId>,
event: &DomainEvent,
) -> Result<RelayMessage, DomainError> {
let msg_id = id.map(|id| id.value().to_string());
let payload = serde_json::to_vec(&NatsEventMessage {
event_id: msg_id.clone(),
payload: EventPayload::from(event),
})
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(RelayMessage {
subject: event_to_subject(prefix, event),
payload,
msg_id,
})
}
enum Transport {
Core(Client),
JetStream(jetstream::Context),
}
pub struct NatsRelaySink {
transport: Transport,
subject_prefix: String,
}
impl NatsRelaySink {
pub fn new_core(client: Client, subject_prefix: String) -> Self {
Self {
transport: Transport::Core(client),
subject_prefix,
}
}
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
Self {
transport: Transport::JetStream(jetstream::new(client)),
subject_prefix,
}
}
}
fn infra(e: impl ToString) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl EventRelaySink for NatsRelaySink {
async fn relay(&self, id: Option<&EventId>, event: &DomainEvent) -> Result<(), DomainError> {
let message = relay_message(&self.subject_prefix, id, event)?;
match &self.transport {
Transport::Core(client) => client
.publish(message.subject, message.payload.into())
.await
.map_err(infra),
Transport::JetStream(js) => {
let mut headers = HeaderMap::new();
if let Some(msg_id) = &message.msg_id {
headers.insert(NATS_MESSAGE_ID, msg_id.as_str());
}
js.publish_with_headers(message.subject, headers, message.payload.into())
.await
.map_err(infra)?
.await
.map(|_| ())
.map_err(infra)
}
}
}
}
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<NatsRelaySink>();
}
#[cfg(test)]
#[path = "tests/relay.rs"]
mod tests;

View File

@@ -1,30 +0,0 @@
use super::*;
#[test]
fn errors_without_nats_url() {
assert!(NatsConfig::from_vars(None, None, None, None, None).is_err());
}
#[test]
fn defaults_with_only_url() {
let cfg = NatsConfig::from_vars(Some("nats://localhost:4222"), None, None, None, None).unwrap();
assert_eq!(cfg.url, "nats://localhost:4222");
assert_eq!(cfg.mode, NatsMode::JetStream);
assert_eq!(cfg.subject_prefix, "movies-diary.events");
assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS");
assert_eq!(cfg.consumer_name, "worker");
}
#[test]
fn core_mode_parsed() {
let cfg =
NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
assert_eq!(cfg.mode, NatsMode::Core);
}
#[test]
fn invalid_mode_errors() {
assert!(
NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()
);
}

View File

@@ -0,0 +1,108 @@
use super::*;
use domain::value_objects::UserId;
use event_payload::{EventMessage, EventPayload};
use uuid::Uuid;
fn payload() -> EventPayload {
EventPayload::from(&DomainEvent::UserUpdated {
user_id: UserId::from_uuid(Uuid::new_v4()),
})
}
fn encode(event_id: Option<&str>) -> Vec<u8> {
serde_json::to_vec(&EventMessage {
event_id: event_id.map(str::to_owned),
payload: payload(),
})
.unwrap()
}
#[test]
fn decode_message_reads_the_event_id() {
let id = Uuid::new_v4();
let (decoded, event) = decode_message(&encode(Some(&id.to_string()))).unwrap();
assert_eq!(decoded.unwrap().value(), id);
assert!(matches!(event, DomainEvent::UserUpdated { .. }));
}
#[test]
fn decode_message_accepts_a_legacy_payload_without_an_id() {
let legacy = serde_json::to_vec(&payload()).unwrap();
let (decoded, _) = decode_message(&legacy).unwrap();
assert!(decoded.is_none());
let (decoded, _) = decode_message(&encode(None)).unwrap();
assert!(decoded.is_none());
}
#[test]
fn decode_message_rejects_a_malformed_id() {
let err = decode_message(&encode(Some("not-a-uuid"))).unwrap_err();
assert!(err.to_string().contains("event_id"), "{err}");
}
#[test]
fn decode_message_rejects_garbage() {
assert!(decode_message(b"{not json").is_err());
}
#[test]
fn stream_with_the_default_window_is_updated_and_keeps_other_fields() {
let existing = StreamConfig {
name: "S".into(),
max_messages: 42,
duplicate_window: std::time::Duration::from_secs(120),
..Default::default()
};
let updated = stream_config_update(&existing).unwrap();
assert_eq!(updated.duplicate_window, DEDUP_WINDOW);
assert_eq!(updated.max_messages, 42);
assert_eq!(updated.name, "S");
}
#[test]
fn stream_with_the_right_window_is_left_alone() {
let existing = StreamConfig {
duplicate_window: DEDUP_WINDOW,
..Default::default()
};
assert!(stream_config_update(&existing).is_none());
}
#[test]
fn consumer_max_deliver_drift_is_updated() {
let existing = jetstream::consumer::Config {
durable_name: Some("c".into()),
filter_subject: "a.>".into(),
max_deliver: -1,
ack_wait: ACK_WAIT,
max_ack_pending: 77,
description: Some("operator".into()),
..Default::default()
};
let updated = consumer_config_update(&existing, 5).unwrap();
assert_eq!(updated.max_deliver, 5);
assert_eq!(updated.durable_name.as_deref(), Some("c"));
assert_eq!(updated.filter_subject, "a.>");
assert_eq!(updated.max_ack_pending, 77);
assert_eq!(updated.description.as_deref(), Some("operator"));
let same = jetstream::consumer::Config {
max_deliver: 5,
..existing
};
assert!(consumer_config_update(&same, 5).is_none());
}
#[test]
fn nak_delay_follows_the_shared_backoff_by_delivery_count() {
let secs = |d| nak_plan(d, 5).delay.as_secs();
assert_eq!((secs(1), secs(2), secs(3), secs(4)), (10, 20, 40, 80));
assert_eq!(nak_plan(50, 100).delay.as_secs(), 300);
}
#[test]
fn only_the_final_delivery_is_exhausted() {
assert!(!nak_plan(4, 5).exhausted);
assert!(nak_plan(5, 5).exhausted);
assert!(nak_plan(6, 5).exhausted);
assert!(!nak_plan(1_000, -1).exhausted, "-1 means unlimited");
}

View File

@@ -0,0 +1,37 @@
use super::*;
use domain::value_objects::UserId;
use uuid::Uuid;
fn event() -> DomainEvent {
DomainEvent::UserUpdated {
user_id: UserId::from_uuid(Uuid::new_v4()),
}
}
#[test]
fn relaying_the_same_row_twice_sets_the_same_msg_id() {
let id = EventId::new();
let event = event();
let first = relay_message("p", Some(&id), &event).unwrap();
let second = relay_message("p", Some(&id), &event).unwrap();
assert_eq!(first.msg_id, Some(id.value().to_string()));
assert_eq!(first.msg_id, second.msg_id);
assert_eq!(first.subject, "p.user.updated");
}
#[test]
fn a_legacy_row_without_an_id_has_no_msg_id() {
let message = relay_message("p", None, &event()).unwrap();
assert_eq!(message.msg_id, None);
let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap();
assert!(decoded.get("event_id").is_none());
}
#[test]
fn the_body_carries_the_event_id_for_the_consumer() {
let id = EventId::new();
let message = relay_message("p", Some(&id), &event()).unwrap();
let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap();
assert_eq!(decoded["event_id"], id.value().to_string());
assert_eq!(decoded["type"], "UserUpdated");
}

View File

@@ -0,0 +1,70 @@
use nats::{NatsConfig, NatsMode};
fn section(url: Option<&str>, mode: &str) -> config::NatsConfig {
config::NatsConfig {
url: url.map(str::to_owned),
mode: mode.into(),
..Default::default()
}
}
#[test]
fn errors_without_nats_url() {
assert!(
NatsConfig::from_config(
&config::NatsConfig::default(),
&config::EventsConfig::default()
)
.is_err()
);
}
#[test]
fn defaults_with_only_url() {
let cfg = NatsConfig::from_config(
&config::NatsConfig {
url: Some("nats://localhost:4222".into()),
..Default::default()
},
&config::EventsConfig::default(),
)
.unwrap();
assert_eq!(cfg.url, "nats://localhost:4222");
assert_eq!(cfg.mode, NatsMode::JetStream);
assert_eq!(cfg.subject_prefix, "movies-diary.events");
assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS");
assert_eq!(cfg.consumer_name, "worker");
}
#[test]
fn core_mode_parsed() {
let cfg = NatsConfig::from_config(
&section(Some("nats://test:4222"), "core"),
&config::EventsConfig::default(),
)
.unwrap();
assert_eq!(cfg.url, "nats://test:4222");
assert_eq!(cfg.mode, NatsMode::Core);
}
#[test]
fn invalid_mode_errors() {
assert!(
NatsConfig::from_config(
&section(Some("nats://test:4222"), "kafka"),
&config::EventsConfig::default()
)
.is_err()
);
}
#[test]
fn max_deliver_follows_the_queue_attempt_limit() {
let events = config::EventsConfig {
queue_max_attempts: 9,
..Default::default()
};
let cfg =
NatsConfig::from_config(&section(Some("nats://test:4222"), "jetstream"), &events).unwrap();
assert_eq!(cfg.max_deliver, 9);
}