219 lines
7.4 KiB
Rust
219 lines
7.4 KiB
Rust
//! Live-broker integration test for `RumqttPublisher`. ADR-122 §2.2 end-to-end.
|
|
//!
|
|
//! **Skipped silently when `BFLD_MQTT_BROKER` is unset**, so CI runs that lack
|
|
//! a broker stay green. Locally:
|
|
//!
|
|
//! ```text
|
|
//! scoop install mosquitto
|
|
//! mosquitto -v -c mosquitto-allow-anon.conf &
|
|
//! BFLD_MQTT_BROKER=tcp://localhost:1883 \
|
|
//! cargo test -p wifi-densepose-bfld --features mqtt --test mosquitto_integration
|
|
//! ```
|
|
//!
|
|
//! Test discipline (per `feedback_mqtt_integration_test_patterns` memory):
|
|
//! - per-test unique `client_id` (current nanosecond timestamp suffix)
|
|
//! - subscriber eventloop pumped until SubAck arrives before publishing
|
|
//! - explicit `wait_for_n_messages` with timeout — never `loop { iter.recv() }`
|
|
|
|
#![cfg(feature = "mqtt")]
|
|
|
|
use std::env;
|
|
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use rumqttc::{Client, Event, Incoming, MqttOptions, Packet, QoS};
|
|
use wifi_densepose_bfld::{
|
|
publish_event, BfldEvent, PrivacyClass, RumqttPublisher,
|
|
};
|
|
|
|
const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
fn broker_env() -> Option<(String, u16)> {
|
|
let raw = env::var("BFLD_MQTT_BROKER").ok()?;
|
|
let raw = raw.strip_prefix("tcp://").unwrap_or(&raw);
|
|
let mut parts = raw.splitn(2, ':');
|
|
let host = parts.next()?.to_string();
|
|
let port: u16 = parts.next().unwrap_or("1883").parse().ok()?;
|
|
Some((host, port))
|
|
}
|
|
|
|
fn unique_client_id(prefix: &str) -> String {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
format!("{prefix}-{nanos}")
|
|
}
|
|
|
|
fn sample_event(node_id: &str) -> BfldEvent {
|
|
BfldEvent::with_privacy_gating(
|
|
node_id.into(),
|
|
1_700_000_000_000_000_000,
|
|
true,
|
|
0.62,
|
|
2,
|
|
0.88,
|
|
Some("test_zone".into()),
|
|
PrivacyClass::Anonymous,
|
|
Some(0.34),
|
|
Some([0xAB; 32]),
|
|
)
|
|
}
|
|
|
|
/// Spawn a subscriber + a pump thread. Returns the receiver of incoming
|
|
/// `(topic, payload)` pairs and a oneshot signalling SubAck arrival.
|
|
fn spawn_subscriber(
|
|
host: &str,
|
|
port: u16,
|
|
topic_filter: &str,
|
|
) -> (Receiver<(String, String)>, Receiver<()>) {
|
|
let mut opts = MqttOptions::new(unique_client_id("bfld-sub"), host, port);
|
|
opts.set_keep_alive(Duration::from_secs(5));
|
|
let (client, mut connection) = Client::new(opts, 64);
|
|
client
|
|
.subscribe(topic_filter, QoS::AtLeastOnce)
|
|
.expect("subscribe enqueue");
|
|
|
|
let (incoming_tx, incoming_rx) = channel();
|
|
let (suback_tx, suback_rx) = channel();
|
|
thread::spawn(move || {
|
|
for notification in connection.iter() {
|
|
match notification {
|
|
Ok(Event::Incoming(Packet::SubAck(_))) => {
|
|
let _ = suback_tx.send(());
|
|
}
|
|
Ok(Event::Incoming(Incoming::Publish(p))) => {
|
|
let topic = p.topic.clone();
|
|
let payload = String::from_utf8_lossy(&p.payload).to_string();
|
|
if incoming_tx.send((topic, payload)).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
(incoming_rx, suback_rx)
|
|
}
|
|
|
|
fn collect_messages(
|
|
rx: &Receiver<(String, String)>,
|
|
expected_count: usize,
|
|
timeout: Duration,
|
|
) -> Vec<(String, String)> {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut out = Vec::with_capacity(expected_count);
|
|
while out.len() < expected_count {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
match rx.recv_timeout(remaining) {
|
|
Ok(msg) => out.push(msg),
|
|
Err(RecvTimeoutError::Timeout) => break,
|
|
Err(RecvTimeoutError::Disconnected) => break,
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn live_broker_anonymous_event_roundtrips_all_six_topics() {
|
|
let Some((host, port)) = broker_env() else {
|
|
eprintln!(
|
|
"BFLD_MQTT_BROKER unset — skipping live mosquitto roundtrip test. \
|
|
Set e.g. BFLD_MQTT_BROKER=tcp://localhost:1883 to enable."
|
|
);
|
|
return;
|
|
};
|
|
|
|
let node_id = unique_client_id("seed");
|
|
let filter = format!("ruview/{node_id}/bfld/+/state");
|
|
|
|
// Subscriber first so it's ready before the publisher sends.
|
|
let (incoming_rx, suback_rx) = spawn_subscriber(&host, port, &filter);
|
|
suback_rx
|
|
.recv_timeout(SUBSCRIBE_TIMEOUT)
|
|
.expect("SubAck within 5s");
|
|
|
|
// Publisher with its own connection. Spawn a thread iterating the
|
|
// Connection so publishes actually reach the broker.
|
|
let mut opts = MqttOptions::new(unique_client_id("bfld-pub"), &host, port);
|
|
opts.set_keep_alive(Duration::from_secs(5));
|
|
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
|
thread::spawn(move || {
|
|
for _ in pub_connection.iter() { /* drain protocol events */ }
|
|
});
|
|
|
|
// Give the publisher a brief moment to complete CONNECT before publish.
|
|
thread::sleep(Duration::from_millis(200));
|
|
|
|
let event = sample_event(&node_id);
|
|
let count = publish_event(&mut publisher, &event).expect("queue publish");
|
|
assert_eq!(count, 6, "Anonymous + zone publishes 6 topics");
|
|
|
|
let messages = collect_messages(&incoming_rx, 6, RECEIVE_TIMEOUT);
|
|
assert_eq!(
|
|
messages.len(),
|
|
6,
|
|
"broker delivered {} of 6 expected messages",
|
|
messages.len(),
|
|
);
|
|
|
|
// Topic correctness — every expected entity must appear exactly once.
|
|
let topics: Vec<&str> = messages.iter().map(|(t, _)| t.as_str()).collect();
|
|
for entity in [
|
|
"presence",
|
|
"motion",
|
|
"person_count",
|
|
"confidence",
|
|
"zone_activity",
|
|
"identity_risk",
|
|
] {
|
|
assert!(
|
|
topics
|
|
.iter()
|
|
.any(|t| t == &format!("ruview/{node_id}/bfld/{entity}/state").as_str()),
|
|
"missing entity {entity} in delivered topics {topics:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn live_broker_restricted_event_omits_identity_risk() {
|
|
let Some((host, port)) = broker_env() else {
|
|
eprintln!("BFLD_MQTT_BROKER unset — skipping");
|
|
return;
|
|
};
|
|
|
|
let node_id = unique_client_id("seed-r");
|
|
let filter = format!("ruview/{node_id}/bfld/+/state");
|
|
|
|
let (incoming_rx, suback_rx) = spawn_subscriber(&host, port, &filter);
|
|
suback_rx
|
|
.recv_timeout(SUBSCRIBE_TIMEOUT)
|
|
.expect("SubAck within 5s");
|
|
|
|
let mut opts = MqttOptions::new(unique_client_id("bfld-pub-r"), &host, port);
|
|
opts.set_keep_alive(Duration::from_secs(5));
|
|
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
|
thread::spawn(move || for _ in pub_connection.iter() {});
|
|
thread::sleep(Duration::from_millis(200));
|
|
|
|
let mut event = sample_event(&node_id);
|
|
event.privacy_class = PrivacyClass::Restricted;
|
|
event.apply_privacy_gating();
|
|
publish_event(&mut publisher, &event).expect("queue publish");
|
|
|
|
// Expect 5 messages: 6 entities minus identity_risk.
|
|
let messages = collect_messages(&incoming_rx, 6, Duration::from_secs(3));
|
|
assert_eq!(messages.len(), 5);
|
|
assert!(
|
|
!messages.iter().any(|(t, _)| t.contains("identity_risk")),
|
|
"Restricted class must not publish identity_risk topic, got {messages:?}",
|
|
);
|
|
}
|