sync from monorepo @ ffee08f2
This commit is contained in:
@@ -1,207 +0,0 @@
|
||||
//! Integration test: Matrix migration onto StreamRegistry (Phase 4, Task 18).
|
||||
//!
|
||||
//! Scope:
|
||||
//! - `MatrixFactory::kind()` reports `"matrix"`.
|
||||
//! - A fresh `StreamFactoryRegistry` with the factory registered can look it
|
||||
//! up and rejects unknown kinds.
|
||||
//! - Building a Matrix stream from a config with an `archive_wide` scope is
|
||||
//! rejected with `StreamBuildError::Config`.
|
||||
//! - Building a Matrix stream against a not-logged-in service is rejected
|
||||
//! with `StreamBuildError::Transport` (does not panic, does not spin up
|
||||
//! a real Matrix connection).
|
||||
//!
|
||||
//! This does NOT exercise end-to-end Matrix delivery — that requires a
|
||||
//! live homeserver or a stub client, which is outside Task 18's scope.
|
||||
//! The share-side `SessionStream` impl is covered separately by
|
||||
//! `dirigent_matrix` unit tests.
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_auth::{Account, AccountKind, AccountProfile, SecretSource};
|
||||
use dirigent_core::sharing::{
|
||||
MatrixFactory, StreamBuildError, StreamConfig, StreamFactory, StreamFactoryRegistry,
|
||||
};
|
||||
use dirigent_matrix::{MatrixBehaviorConfig, MatrixService};
|
||||
use dirigent_protocol::streaming::StreamScope;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn sample_matrix_account() -> Account {
|
||||
let mut credentials = HashMap::new();
|
||||
credentials.insert(
|
||||
"password".to_string(),
|
||||
SecretSource::Inline {
|
||||
value: "bot-pass".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
properties.insert(
|
||||
"homeserver".to_string(),
|
||||
serde_json::json!("https://matrix.example.com"),
|
||||
);
|
||||
properties.insert(
|
||||
"device_id".to_string(),
|
||||
serde_json::json!("DIRIGENT_TEST"),
|
||||
);
|
||||
|
||||
Account {
|
||||
kind: AccountKind::Matrix,
|
||||
config_name: "matrix-test".to_string(),
|
||||
user_id: None,
|
||||
credentials,
|
||||
profile: AccountProfile {
|
||||
username: Some("bot".to_string()),
|
||||
display_name: Some("Test Bot".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn behavior() -> MatrixBehaviorConfig {
|
||||
MatrixBehaviorConfig {
|
||||
account: "matrix-test".to_string(),
|
||||
mode: Default::default(),
|
||||
default_invite: vec![],
|
||||
store_path: "matrix/test/store".to_string(),
|
||||
rooms: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `MatrixService` without calling `login()`. Any code path that
|
||||
/// needs a live Client will surface a clean error (not a panic).
|
||||
fn not_logged_in_service() -> Arc<MatrixService> {
|
||||
let account = sample_matrix_account();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let data_dir: PathBuf = tmp.path().to_path_buf();
|
||||
// Leak the TempDir so the path survives the life of the service for
|
||||
// the duration of the test. The sqlite store is only created when
|
||||
// login() runs — we never call it in these tests.
|
||||
std::mem::forget(tmp);
|
||||
let service = MatrixService::from_account(&account, behavior(), data_dir)
|
||||
.expect("from_account");
|
||||
Arc::new(service)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn matrix_factory_kind_is_matrix() {
|
||||
let service = not_logged_in_service();
|
||||
let f = MatrixFactory::new(service);
|
||||
assert_eq!(f.kind(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_returns_registered_matrix_factory() {
|
||||
let service = not_logged_in_service();
|
||||
let reg = StreamFactoryRegistry::new().register(MatrixFactory::new(service));
|
||||
assert!(reg.get("matrix").is_some(), "matrix factory should be found");
|
||||
assert!(
|
||||
reg.get("langfuse").is_none(),
|
||||
"unregistered kinds must return None"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_rejects_archive_wide_scope_with_config_error() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
room_id = "!room:example.com"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-wrong-scope".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::ArchiveWide { acknowledged: false },
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
match err {
|
||||
StreamBuildError::Config(msg) => {
|
||||
assert!(
|
||||
msg.contains("session"),
|
||||
"expected 'session' hint in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Config error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_rejects_missing_params_with_config_error() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
// Missing room_id — required field.
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-missing-room".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::Session {
|
||||
scroll_id: Uuid::now_v7(),
|
||||
},
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
assert!(
|
||||
matches!(err, StreamBuildError::Config(_)),
|
||||
"expected Config error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_reports_transport_error_when_service_not_logged_in() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
room_id = "!room:example.com"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-not-logged-in".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::Session {
|
||||
scroll_id: Uuid::now_v7(),
|
||||
},
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
match err {
|
||||
StreamBuildError::Transport(msg) => {
|
||||
assert!(
|
||||
msg.to_lowercase().contains("logged in")
|
||||
|| msg.to_lowercase().contains("matrix service"),
|
||||
"expected transport error to mention login state, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Transport error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
//! Integration test: replay archived session into a `MockStream`.
|
||||
//!
|
||||
//! Builds a single-backend in-memory (tempdir) archivist, registers a
|
||||
//! session, appends 10 messages with ascending timestamps, then exercises
|
||||
//! `replay_session_to_stream` end-to-end.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_archivist::{
|
||||
Archivist, MessageRecord, RegisterConnectorRequest, RegisterSessionRequest,
|
||||
backends::JsonlBackend,
|
||||
};
|
||||
use dirigent_core::sharing::{
|
||||
MockStream,
|
||||
replay::{ReplayOptions, ReplaySpeed, replay_session_to_stream},
|
||||
};
|
||||
use dirigent_protocol::streaming::{EventOrigin, SessionStream, StreamScope};
|
||||
|
||||
/// Build an in-memory-ish archivist backed by a tempdir + JsonlBackend.
|
||||
///
|
||||
/// Matches the pattern used by `dirigent_archivist/tests/integration_tests.rs`.
|
||||
/// The tempdir is leaked for the duration of the test process — acceptable
|
||||
/// because the test binary exits immediately after.
|
||||
async fn build_in_memory_archivist() -> Arc<Archivist> {
|
||||
let temp_dir = std::env::temp_dir().join(format!("core_replay_test_{}", Uuid::now_v7()));
|
||||
let backend = Arc::new(
|
||||
JsonlBackend::new(temp_dir.clone())
|
||||
.await
|
||||
.expect("JsonlBackend construction"),
|
||||
);
|
||||
let archivist = Archivist::from_single_backend("main".into(), backend)
|
||||
.await
|
||||
.expect("Archivist::from_single_backend");
|
||||
Arc::new(archivist)
|
||||
}
|
||||
|
||||
/// Register a fresh connector + session and append `n` messages with
|
||||
/// timestamps one second apart. Returns the scroll_id.
|
||||
async fn seed_session_with_messages(archivist: &Archivist, n: usize) -> Uuid {
|
||||
let connector_resp = archivist
|
||||
.register_connector(
|
||||
RegisterConnectorRequest {
|
||||
r#type: "OpenCode".to_string(),
|
||||
title: "Replay Test Connector".to_string(),
|
||||
client_native_id: format!("replay-test@{}", Uuid::now_v7()),
|
||||
custom_uid: None,
|
||||
metadata: serde_json::json!({}),
|
||||
fingerprint: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register_connector");
|
||||
|
||||
let session_resp = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector_resp.connector_uid,
|
||||
native_session_id: format!("native-{}", Uuid::now_v7()),
|
||||
title: Some("Replay Test Session".to_string()),
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::json!({}),
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register_session");
|
||||
|
||||
let scroll_id = session_resp.scroll_id;
|
||||
let base_ts = Utc::now();
|
||||
|
||||
let messages: Vec<MessageRecord> = (0..n)
|
||||
.map(|i| {
|
||||
let role = if i % 2 == 0 { "user" } else { "assistant" };
|
||||
MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session: scroll_id,
|
||||
parent_id: None,
|
||||
ts: base_ts + ChronoDuration::seconds(i as i64),
|
||||
role: role.to_string(),
|
||||
author: None,
|
||||
content_md: format!("message {i}"),
|
||||
content_parts: None,
|
||||
attachments: vec![],
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
archivist
|
||||
.append_messages(scroll_id, messages, None)
|
||||
.await
|
||||
.expect("append_messages");
|
||||
|
||||
scroll_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_delivers_archived_messages_to_stream() {
|
||||
let archivist = build_in_memory_archivist().await;
|
||||
let scroll_id = seed_session_with_messages(&archivist, 10).await;
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
let stream: Arc<dyn SessionStream> = mock.clone();
|
||||
|
||||
let report = replay_session_to_stream(
|
||||
archivist.as_ref(),
|
||||
scroll_id,
|
||||
stream,
|
||||
ReplayOptions {
|
||||
include_meta_events: false,
|
||||
speed: ReplaySpeed::AsFastAsPossible,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("replay_session_to_stream");
|
||||
|
||||
assert_eq!(report.events_sent, 10, "events_sent");
|
||||
assert_eq!(report.failures, 0, "failures");
|
||||
assert_eq!(mock.received_count(), 10, "mock received count");
|
||||
|
||||
let received = mock.received.lock().unwrap();
|
||||
for evt in received.iter() {
|
||||
assert!(
|
||||
matches!(evt.origin, EventOrigin::Replay { .. }),
|
||||
"every replayed event must carry EventOrigin::Replay"
|
||||
);
|
||||
assert_eq!(
|
||||
evt.routing.scroll_id,
|
||||
Some(scroll_id),
|
||||
"every replayed event must carry the authoritative scroll_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_continues_on_stream_failure() {
|
||||
let archivist = build_in_memory_archivist().await;
|
||||
let scroll_id = seed_session_with_messages(&archivist, 10).await;
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
mock.fail_next(3);
|
||||
let stream: Arc<dyn SessionStream> = mock.clone();
|
||||
|
||||
let report = replay_session_to_stream(
|
||||
archivist.as_ref(),
|
||||
scroll_id,
|
||||
stream,
|
||||
ReplayOptions {
|
||||
include_meta_events: false,
|
||||
speed: ReplaySpeed::AsFastAsPossible,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("replay_session_to_stream");
|
||||
|
||||
// events_sent counts attempted (ok + failed); failures counts Failed only.
|
||||
assert_eq!(report.events_sent, 10, "events_sent counts every attempt");
|
||||
assert_eq!(report.failures, 3, "first 3 events rejected by mock");
|
||||
assert_eq!(
|
||||
mock.received_count(),
|
||||
7,
|
||||
"mock buffer contains the 7 successful events"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user