huskies: merge 599_story_cross_project_status_notifications_in_chat
This commit is contained in:
@@ -2223,6 +2223,135 @@ fn spawn_gateway_bot(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cross-project notification poller ─────────────────────────────────
|
||||
|
||||
/// Spawn a background task that polls `GET /api/events?since={ts_ms}` on every
|
||||
/// registered project server and forwards new events to the gateway's chat rooms.
|
||||
///
|
||||
/// Each event is prefixed with `[project-name]` so users can distinguish which
|
||||
/// project emitted the notification. Unreachable projects produce a log warning
|
||||
/// and are skipped; the poller continues with all other projects.
|
||||
///
|
||||
/// This is only called when the gateway bot is enabled (`bot.toml enabled = true`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` — the gateway-level [`ChatTransport`] used to send messages.
|
||||
/// * `room_ids` — the list of room IDs to send notifications to.
|
||||
/// * `project_urls` — the map of project name → base URL (e.g. `http://host:3001`).
|
||||
/// * `poll_interval_secs` — how often to poll each project (default 5).
|
||||
pub fn spawn_gateway_notification_poller(
|
||||
transport: Arc<dyn crate::chat::ChatTransport>,
|
||||
room_ids: Vec<String>,
|
||||
project_urls: BTreeMap<String, String>,
|
||||
poll_interval_secs: u64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
let interval = std::time::Duration::from_secs(poll_interval_secs.max(1));
|
||||
|
||||
// Track the last seen timestamp per project so we only receive new events.
|
||||
let mut last_ts: HashMap<String, u64> = project_urls
|
||||
.keys()
|
||||
.map(|name| (name.clone(), 0u64))
|
||||
.collect();
|
||||
|
||||
loop {
|
||||
for (project_name, base_url) in &project_urls {
|
||||
let since = last_ts.get(project_name).copied().unwrap_or(0);
|
||||
let url = format!("{base_url}/api/events?since={since}");
|
||||
|
||||
let response = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::slog!(
|
||||
"[gateway-poller] {project_name}: unreachable ({e}); skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let events: Vec<crate::http::events::StoredEvent> = match response.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::slog!(
|
||||
"[gateway-poller] {project_name}: failed to parse events: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for event in &events {
|
||||
// Advance the cursor.
|
||||
let ts = event.timestamp_ms();
|
||||
if ts > *last_ts.get(project_name).unwrap_or(&0) {
|
||||
last_ts.insert(project_name.clone(), ts);
|
||||
}
|
||||
|
||||
let (plain, html) = format_gateway_event(project_name, event);
|
||||
for room_id in &room_ids {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
crate::slog!(
|
||||
"[gateway-poller] Failed to send notification to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Format a [`crate::http::events::StoredEvent`] from a project into a gateway notification.
|
||||
///
|
||||
/// Prefixes the message with `[project-name]` so users can distinguish which
|
||||
/// project emitted the event. Story names are not available at the gateway
|
||||
/// level, so the item ID is used as a fallback (the formatting functions
|
||||
/// extract the numeric story number from it automatically).
|
||||
fn format_gateway_event(
|
||||
project_name: &str,
|
||||
event: &crate::http::events::StoredEvent,
|
||||
) -> (String, String) {
|
||||
use crate::chat::transport::matrix::notifications::{
|
||||
format_blocked_notification, format_error_notification, format_stage_notification,
|
||||
stage_display_name,
|
||||
};
|
||||
use crate::http::events::StoredEvent;
|
||||
|
||||
let prefix = format!("[{project_name}] ");
|
||||
|
||||
match event {
|
||||
StoredEvent::StageTransition {
|
||||
story_id,
|
||||
from_stage,
|
||||
to_stage,
|
||||
..
|
||||
} => {
|
||||
let from_display = stage_display_name(from_stage);
|
||||
let to_display = stage_display_name(to_stage);
|
||||
let (plain, html) = format_stage_notification(story_id, None, from_display, to_display);
|
||||
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
|
||||
}
|
||||
StoredEvent::MergeFailure {
|
||||
story_id, reason, ..
|
||||
} => {
|
||||
let (plain, html) = format_error_notification(story_id, None, reason);
|
||||
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
|
||||
}
|
||||
StoredEvent::StoryBlocked {
|
||||
story_id, reason, ..
|
||||
} => {
|
||||
let (plain, html) = format_blocked_notification(story_id, None, reason);
|
||||
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2673,6 +2802,233 @@ enabled = false
|
||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// AC5: When one project server is unreachable the poller continues delivering
|
||||
/// events from the remaining reachable projects without failing.
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_continues_when_one_project_unreachable() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::http::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<String>>>;
|
||||
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for MockTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push(plain.to_string());
|
||||
Ok("id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(MockTransport {
|
||||
calls: Arc::clone(&calls),
|
||||
});
|
||||
|
||||
// Start a reachable mock project server that returns one event.
|
||||
let event = vec![StoredEvent::StoryBlocked {
|
||||
story_id: "10_story_ok".to_string(),
|
||||
reason: "retry limit".to_string(),
|
||||
timestamp_ms: 500,
|
||||
}];
|
||||
let event_body = serde_json::to_vec(&event).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let good_port = listener.local_addr().unwrap().port();
|
||||
let good_url = format!("http://127.0.0.1:{good_port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
event_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&event_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
// An unreachable URL (port 1 cannot be bound).
|
||||
let bad_url = "http://127.0.0.1:1".to_string();
|
||||
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("good-project".to_string(), good_url);
|
||||
project_urls.insert("unreachable-project".to_string(), bad_url);
|
||||
|
||||
spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec!["!room:example.org".to_string()],
|
||||
project_urls,
|
||||
1,
|
||||
);
|
||||
|
||||
// Wait for at least one poll cycle.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
// Events from the reachable project must still arrive.
|
||||
let messages = calls.lock().unwrap();
|
||||
assert!(
|
||||
!messages.is_empty(),
|
||||
"Expected notifications from the reachable project; got none"
|
||||
);
|
||||
let has_good = messages
|
||||
.iter()
|
||||
.any(|m| m.contains("[good-project]") && m.contains("10_story_ok"));
|
||||
assert!(
|
||||
has_good,
|
||||
"Expected a notification from [good-project]; got: {messages:?}"
|
||||
);
|
||||
// Unreachable project must not produce any notifications.
|
||||
let has_bad = messages.iter().any(|m| m.contains("[unreachable-project]"));
|
||||
assert!(
|
||||
!has_bad,
|
||||
"Unreachable project must not produce notifications; got: {messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4: When both a per-project bot and the gateway aggregated stream are
|
||||
/// configured, events go to each room exactly once.
|
||||
///
|
||||
/// The gateway notification poller only sends to the gateway room IDs it is
|
||||
/// given — it never forwards events to per-project rooms. Conversely, the
|
||||
/// per-project notification listener subscribes to watcher broadcasts which
|
||||
/// are completely separate from the HTTP-polled event buffer. This test
|
||||
/// verifies the poller respects its room list (no cross-room leakage).
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::http::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type RoomLog = Arc<std::sync::Mutex<Vec<String>>>;
|
||||
|
||||
struct RoomCapture {
|
||||
rooms: RoomLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for RoomCapture {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.rooms.lock().unwrap().push(room_id.to_string());
|
||||
Ok("id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let rooms: RoomLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(RoomCapture {
|
||||
rooms: Arc::clone(&rooms),
|
||||
});
|
||||
|
||||
// Serve one event from a mock project server.
|
||||
let event = vec![StoredEvent::MergeFailure {
|
||||
story_id: "5_story_x".to_string(),
|
||||
reason: "conflict".to_string(),
|
||||
timestamp_ms: 300,
|
||||
}];
|
||||
let event_body = serde_json::to_vec(&event).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
event_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&event_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
const GATEWAY_ROOM: &str = "!gateway:example.org";
|
||||
const PER_PROJECT_ROOM: &str = "!project:example.org";
|
||||
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("myproj".to_string(), url);
|
||||
|
||||
// Poller is given only the gateway room — per-project room must never receive.
|
||||
spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec![GATEWAY_ROOM.to_string()],
|
||||
project_urls,
|
||||
1,
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let room_calls = rooms.lock().unwrap();
|
||||
// Every notification must go to the gateway room only.
|
||||
assert!(
|
||||
!room_calls.is_empty(),
|
||||
"Expected at least one notification; got none"
|
||||
);
|
||||
for room in room_calls.iter() {
|
||||
assert_eq!(
|
||||
room, GATEWAY_ROOM,
|
||||
"Notification must only go to the gateway room, not {room}"
|
||||
);
|
||||
}
|
||||
// The per-project room must never have been contacted.
|
||||
assert!(
|
||||
!room_calls.iter().any(|r| r == PER_PROJECT_ROOM),
|
||||
"Per-project room must not receive gateway aggregated notifications"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the full gateway route tree and verify it does not panic.
|
||||
///
|
||||
/// Poem panics at construction time when duplicate routes are registered.
|
||||
@@ -3136,4 +3492,207 @@ enabled = false
|
||||
"unreachable project must have error field: {broken}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── format_gateway_event unit tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_gateway_event_stage_transition_prefixes_project_name() {
|
||||
use crate::http::events::StoredEvent;
|
||||
|
||||
let event = StoredEvent::StageTransition {
|
||||
story_id: "42_story_my_feature".to_string(),
|
||||
from_stage: "2_current".to_string(),
|
||||
to_stage: "3_qa".to_string(),
|
||||
timestamp_ms: 1000,
|
||||
};
|
||||
let (plain, html) = format_gateway_event("huskies", &event);
|
||||
assert!(plain.starts_with("[huskies] "), "plain: {plain}");
|
||||
assert!(html.starts_with("[huskies] "), "html: {html}");
|
||||
assert!(plain.contains("Current"), "plain: {plain}");
|
||||
assert!(plain.contains("QA"), "plain: {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_gateway_event_merge_failure_prefixes_project_name() {
|
||||
use crate::http::events::StoredEvent;
|
||||
|
||||
let event = StoredEvent::MergeFailure {
|
||||
story_id: "42_story_my_feature".to_string(),
|
||||
reason: "merge conflict".to_string(),
|
||||
timestamp_ms: 1000,
|
||||
};
|
||||
let (plain, _html) = format_gateway_event("robot-studio", &event);
|
||||
assert!(plain.starts_with("[robot-studio] "), "plain: {plain}");
|
||||
assert!(plain.contains("merge conflict"), "plain: {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_gateway_event_story_blocked_prefixes_project_name() {
|
||||
use crate::http::events::StoredEvent;
|
||||
|
||||
let event = StoredEvent::StoryBlocked {
|
||||
story_id: "43_story_bar".to_string(),
|
||||
reason: "retry limit exceeded".to_string(),
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
let (plain, _html) = format_gateway_event("huskies", &event);
|
||||
assert!(plain.starts_with("[huskies] "), "plain: {plain}");
|
||||
assert!(plain.contains("BLOCKED"), "plain: {plain}");
|
||||
}
|
||||
|
||||
/// AC7 integration test: two mock HTTP servers, trigger events, assert
|
||||
/// aggregated stream gets both with project tags.
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_delivers_events_from_two_projects_with_project_tags() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::http::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// ── MockTransport ──────────────────────────────────────────────────
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
|
||||
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for MockTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((
|
||||
room_id.to_string(),
|
||||
plain.to_string(),
|
||||
html.to_string(),
|
||||
));
|
||||
Ok("mock-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(MockTransport {
|
||||
calls: Arc::clone(&calls),
|
||||
});
|
||||
|
||||
// ── Mock HTTP server for project "alpha" ───────────────────────────
|
||||
let alpha_events = vec![StoredEvent::StageTransition {
|
||||
story_id: "1_story_alpha".to_string(),
|
||||
from_stage: "2_current".to_string(),
|
||||
to_stage: "3_qa".to_string(),
|
||||
timestamp_ms: 100,
|
||||
}];
|
||||
let alpha_body = serde_json::to_vec(&alpha_events).unwrap();
|
||||
let alpha_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let alpha_port = alpha_listener.local_addr().unwrap().port();
|
||||
let alpha_url = format!("http://127.0.0.1:{alpha_port}");
|
||||
tokio::spawn(async move {
|
||||
// Handle two requests (poller might poll more than once).
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = alpha_listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
alpha_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&alpha_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mock HTTP server for project "beta" ────────────────────────────
|
||||
let beta_events = vec![StoredEvent::MergeFailure {
|
||||
story_id: "2_story_beta".to_string(),
|
||||
reason: "merge conflict in lib.rs".to_string(),
|
||||
timestamp_ms: 200,
|
||||
}];
|
||||
let beta_body = serde_json::to_vec(&beta_events).unwrap();
|
||||
let beta_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let beta_port = beta_listener.local_addr().unwrap().port();
|
||||
let beta_url = format!("http://127.0.0.1:{beta_port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = beta_listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
beta_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&beta_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Give mock servers a moment to bind.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
// ── Run the poller ─────────────────────────────────────────────────
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("alpha".to_string(), alpha_url);
|
||||
project_urls.insert("beta".to_string(), beta_url);
|
||||
|
||||
spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec!["!room:example.org".to_string()],
|
||||
project_urls,
|
||||
1, // poll every 1 second
|
||||
);
|
||||
|
||||
// Wait long enough for at least one poll cycle.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
// ── Assert both events appear with correct [project-name] prefix ───
|
||||
let calls = calls.lock().unwrap();
|
||||
assert!(
|
||||
!calls.is_empty(),
|
||||
"Expected at least one notification; got none"
|
||||
);
|
||||
|
||||
let plains: Vec<&str> = calls.iter().map(|(_, p, _)| p.as_str()).collect();
|
||||
|
||||
let alpha_notification = plains
|
||||
.iter()
|
||||
.any(|p| p.contains("[alpha]") && p.contains("1"));
|
||||
let beta_notification = plains
|
||||
.iter()
|
||||
.any(|p| p.contains("[beta]") && p.contains("merge conflict"));
|
||||
|
||||
assert!(
|
||||
alpha_notification,
|
||||
"Expected a notification from [alpha] containing story ID '1'; got: {plains:?}"
|
||||
);
|
||||
assert!(
|
||||
beta_notification,
|
||||
"Expected a notification from [beta] containing 'merge conflict'; got: {plains:?}"
|
||||
);
|
||||
|
||||
// All notifications must go to the configured room.
|
||||
for (room_id, _, _) in calls.iter() {
|
||||
assert_eq!(
|
||||
room_id, "!room:example.org",
|
||||
"All notifications must go to the gateway room"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user