diff --git a/crates/crank-community-mcp/src/session.rs b/crates/crank-community-mcp/src/session.rs index e2f05c9..af7aa2a 100644 --- a/crates/crank-community-mcp/src/session.rs +++ b/crates/crank-community-mcp/src/session.rs @@ -331,220 +331,3 @@ fn is_expired(session: &SessionState, now: OffsetDateTime) -> bool { .expires_at .is_some_and(|expires_at| expires_at <= now) } - -#[cfg(test)] -mod tests { - use std::env; - - use sqlx::{ - Executor, - postgres::{PgConnectOptions, PgPoolOptions}, - }; - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - use uuid::Uuid; - - use super::{ - InMemorySessionStore, PostgresTransportSessionStore, SessionStoreError, - TransportSessionStore, - }; - use crank_registry::PostgresPoolConfig; - - fn timestamp(value: &str) -> time::OffsetDateTime { - time::OffsetDateTime::parse(value, &Rfc3339).unwrap() - } - - fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime { - value - .replace_nanosecond((value.nanosecond() / 1_000) * 1_000) - .unwrap() - } - - #[tokio::test] - async fn creates_and_reads_transport_sessions() { - let store = InMemorySessionStore::default(); - let created_at = timestamp("2026-05-01T10:00:00Z"); - - let session_id = store - .create("2025-11-25", "default", "sales", created_at, None) - .await - .unwrap(); - let session = store.get(&session_id).await.unwrap().unwrap(); - - assert_eq!(session.id, session_id); - assert_eq!(session.protocol_version, "2025-11-25"); - assert_eq!(session.workspace_slug, "default"); - assert_eq!(session.agent_slug, "sales"); - assert!(!session.initialized); - assert_eq!(session.created_at, created_at); - assert_eq!(session.updated_at, created_at); - } - - #[tokio::test] - async fn marks_transport_sessions_initialized() { - let store = InMemorySessionStore::default(); - let created_at = timestamp("2026-05-01T10:00:00Z"); - let initialized_at = timestamp("2026-05-01T10:00:05Z"); - - let session_id = store - .create("2025-11-25", "default", "sales", created_at, None) - .await - .unwrap(); - - assert!( - store - .mark_initialized(&session_id, initialized_at) - .await - .unwrap() - ); - - let session = store.get(&session_id).await.unwrap().unwrap(); - assert!(session.initialized); - assert_eq!(session.updated_at, initialized_at); - } - - #[tokio::test] - async fn drops_expired_in_memory_transport_sessions_on_read() { - let store = InMemorySessionStore::default(); - let created_at = timestamp("2026-05-01T10:00:00Z"); - let expires_at = timestamp("2026-05-01T10:00:01Z"); - - let session_id = store - .create( - "2025-11-25", - "default", - "sales", - created_at, - Some(expires_at), - ) - .await - .unwrap(); - - assert!(store.get(&session_id).await.unwrap().is_none()); - } - - #[test] - fn formats_transport_session_store_error() { - let error = SessionStoreError { - details: "postgres is unavailable".to_owned(), - }; - - assert_eq!( - error.to_string(), - "transport session store is unavailable: postgres is unavailable" - ); - } - - #[tokio::test] - async fn postgres_transport_sessions_survive_store_reconnect() { - let database_url = env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); - let admin_pool = PgPoolOptions::new() - .max_connections(1) - .connect(&database_url) - .await - .unwrap(); - let schema = format!("test_mcp_transport_{}", Uuid::now_v7().simple()); - - admin_pool - .execute(sqlx::query(&format!("create schema {schema}"))) - .await - .unwrap(); - - let connect_options = format!("{database_url}?options=-csearch_path%3D{schema}") - .parse::() - .unwrap(); - let pool_config = PostgresPoolConfig::default(); - let store_a = PostgresTransportSessionStore::connect_with_options_and_pool_config( - connect_options.clone(), - pool_config, - ) - .await - .unwrap(); - - let created_at = OffsetDateTime::now_utc() - time::Duration::hours(1); - let initialized_at = created_at + time::Duration::seconds(5); - let session_id = store_a - .create( - "2025-11-25", - "default", - "sales", - created_at, - Some(created_at + time::Duration::days(30)), - ) - .await - .unwrap(); - store_a - .mark_initialized(&session_id, initialized_at) - .await - .unwrap(); - - let store_b = PostgresTransportSessionStore::connect_with_options_and_pool_config( - connect_options, - pool_config, - ) - .await - .unwrap(); - let session = store_b.get(&session_id).await.unwrap().unwrap(); - - assert_eq!(session.id, session_id); - assert!(session.initialized); - assert_eq!(session.updated_at, truncate_to_micros(initialized_at)); - assert_eq!(session.workspace_slug, "default"); - assert_eq!(session.agent_slug, "sales"); - } - - #[tokio::test] - async fn postgres_transport_sessions_evict_expired_rows_on_read() { - let database_url = env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); - let admin_pool = PgPoolOptions::new() - .max_connections(1) - .connect(&database_url) - .await - .unwrap(); - let schema = format!("test_mcp_transport_{}", Uuid::now_v7().simple()); - - admin_pool - .execute(sqlx::query(&format!("create schema {schema}"))) - .await - .unwrap(); - - let connect_options = format!("{database_url}?options=-csearch_path%3D{schema}") - .parse::() - .unwrap(); - let store = PostgresTransportSessionStore::connect_with_options_and_pool_config( - connect_options.clone(), - PostgresPoolConfig::default(), - ) - .await - .unwrap(); - - let session_id = store - .create( - "2025-11-25", - "default", - "sales", - timestamp("2026-05-01T10:00:00Z"), - Some(timestamp("2026-05-01T10:00:01Z")), - ) - .await - .unwrap(); - - assert!(store.get(&session_id).await.unwrap().is_none()); - - let pool = PgPoolOptions::new() - .max_connections(1) - .connect_with(connect_options) - .await - .unwrap(); - let remaining = sqlx::query_scalar::<_, i64>( - "select count(*) from mcp_transport_sessions where id = $1", - ) - .bind(&session_id) - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(remaining, 0); - } -} diff --git a/crates/crank-community-mcp/tests/integration.rs b/crates/crank-community-mcp/tests/integration.rs new file mode 100644 index 0000000..a195f45 --- /dev/null +++ b/crates/crank-community-mcp/tests/integration.rs @@ -0,0 +1,2 @@ +#[path = "integration/session.rs"] +mod session; diff --git a/crates/crank-community-mcp/tests/integration/session.rs b/crates/crank-community-mcp/tests/integration/session.rs new file mode 100644 index 0000000..9704696 --- /dev/null +++ b/crates/crank-community-mcp/tests/integration/session.rs @@ -0,0 +1,133 @@ +use std::env; + +use crank_community_mcp::session::{PostgresTransportSessionStore, TransportSessionStore}; +use crank_registry::PostgresPoolConfig; +use sqlx::{ + Executor, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +fn timestamp(value: &str) -> time::OffsetDateTime { + time::OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime { + value + .replace_nanosecond((value.nanosecond() / 1_000) * 1_000) + .unwrap() +} + +#[tokio::test] +async fn postgres_transport_sessions_survive_store_reconnect() { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .unwrap(); + let schema = format!("test_mcp_transport_{}", Uuid::now_v7().simple()); + + admin_pool + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + + let connect_options = format!("{database_url}?options=-csearch_path%3D{schema}") + .parse::() + .unwrap(); + let pool_config = PostgresPoolConfig::default(); + let store_a = PostgresTransportSessionStore::connect_with_options_and_pool_config( + connect_options.clone(), + pool_config, + ) + .await + .unwrap(); + + let created_at = OffsetDateTime::now_utc() - time::Duration::hours(1); + let initialized_at = created_at + time::Duration::seconds(5); + let session_id = store_a + .create( + "2025-11-25", + "default", + "sales", + created_at, + Some(created_at + time::Duration::days(30)), + ) + .await + .unwrap(); + store_a + .mark_initialized(&session_id, initialized_at) + .await + .unwrap(); + + let store_b = PostgresTransportSessionStore::connect_with_options_and_pool_config( + connect_options, + pool_config, + ) + .await + .unwrap(); + let session = store_b.get(&session_id).await.unwrap().unwrap(); + + assert_eq!(session.id, session_id); + assert!(session.initialized); + assert_eq!(session.updated_at, truncate_to_micros(initialized_at)); + assert_eq!(session.workspace_slug, "default"); + assert_eq!(session.agent_slug, "sales"); +} + +#[tokio::test] +async fn postgres_transport_sessions_evict_expired_rows_on_read() { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .unwrap(); + let schema = format!("test_mcp_transport_{}", Uuid::now_v7().simple()); + + admin_pool + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + + let connect_options = format!("{database_url}?options=-csearch_path%3D{schema}") + .parse::() + .unwrap(); + let store = PostgresTransportSessionStore::connect_with_options_and_pool_config( + connect_options.clone(), + PostgresPoolConfig::default(), + ) + .await + .unwrap(); + + let session_id = store + .create( + "2025-11-25", + "default", + "sales", + timestamp("2026-05-01T10:00:00Z"), + Some(timestamp("2026-05-01T10:00:01Z")), + ) + .await + .unwrap(); + + assert!(store.get(&session_id).await.unwrap().is_none()); + + let pool = PgPoolOptions::new() + .max_connections(1) + .connect_with(connect_options) + .await + .unwrap(); + let remaining = + sqlx::query_scalar::<_, i64>("select count(*) from mcp_transport_sessions where id = $1") + .bind(&session_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(remaining, 0); +} diff --git a/crates/crank-community-mcp/tests/unit.rs b/crates/crank-community-mcp/tests/unit.rs index 8a0c99f..3bbe3cf 100644 --- a/crates/crank-community-mcp/tests/unit.rs +++ b/crates/crank-community-mcp/tests/unit.rs @@ -1,4 +1,6 @@ #[path = "unit/manifest.rs"] mod manifest; +#[path = "unit/session.rs"] +mod session; #[path = "unit/tool_error.rs"] mod tool_error; diff --git a/crates/crank-community-mcp/tests/unit/session.rs b/crates/crank-community-mcp/tests/unit/session.rs new file mode 100644 index 0000000..70d2bc0 --- /dev/null +++ b/crates/crank-community-mcp/tests/unit/session.rs @@ -0,0 +1,83 @@ +use crank_community_mcp::session::{ + InMemorySessionStore, SessionStoreError, TransportSessionStore, +}; +use time::format_description::well_known::Rfc3339; + +fn timestamp(value: &str) -> time::OffsetDateTime { + time::OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +#[tokio::test] +async fn creates_and_reads_transport_sessions() { + let store = InMemorySessionStore::default(); + let created_at = timestamp("2026-05-01T10:00:00Z"); + + let session_id = store + .create("2025-11-25", "default", "sales", created_at, None) + .await + .unwrap(); + let session = store.get(&session_id).await.unwrap().unwrap(); + + assert_eq!(session.id, session_id); + assert_eq!(session.protocol_version, "2025-11-25"); + assert_eq!(session.workspace_slug, "default"); + assert_eq!(session.agent_slug, "sales"); + assert!(!session.initialized); + assert_eq!(session.created_at, created_at); + assert_eq!(session.updated_at, created_at); +} + +#[tokio::test] +async fn marks_transport_sessions_initialized() { + let store = InMemorySessionStore::default(); + let created_at = timestamp("2026-05-01T10:00:00Z"); + let initialized_at = timestamp("2026-05-01T10:00:05Z"); + + let session_id = store + .create("2025-11-25", "default", "sales", created_at, None) + .await + .unwrap(); + + assert!( + store + .mark_initialized(&session_id, initialized_at) + .await + .unwrap() + ); + + let session = store.get(&session_id).await.unwrap().unwrap(); + assert!(session.initialized); + assert_eq!(session.updated_at, initialized_at); +} + +#[tokio::test] +async fn drops_expired_in_memory_transport_sessions_on_read() { + let store = InMemorySessionStore::default(); + let created_at = timestamp("2026-05-01T10:00:00Z"); + let expires_at = timestamp("2026-05-01T10:00:01Z"); + + let session_id = store + .create( + "2025-11-25", + "default", + "sales", + created_at, + Some(expires_at), + ) + .await + .unwrap(); + + assert!(store.get(&session_id).await.unwrap().is_none()); +} + +#[test] +fn formats_transport_session_store_error() { + let error = SessionStoreError { + details: "postgres is unavailable".to_owned(), + }; + + assert_eq!( + error.to_string(), + "transport session store is unavailable: postgres is unavailable" + ); +}