use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request};
use axum::response::Response;
use axum::Router;
use tower::ServiceExt;
use crate::agent::ToolRegistry;
use crate::auth::{AuthContext, AuthError, Authentication};
use crate::config::AppConfig;
use crate::state::AppState;
use crate::thingd::{MemoryThingdBackend, ThingdBackend};
pub struct TestApp {
state: AppState,
router: Router,
}
impl TestApp {
pub fn builder() -> TestAppBuilder {
TestAppBuilder::new()
}
pub async fn request(&self, method: Method, path: &str) -> Response {
self.router
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap(),
)
.await
.expect("failed to make request")
}
pub async fn get(&self, path: &str) -> Response {
self.request(Method::GET, path).await
}
pub async fn post_json(&self, path: &str, body: serde_json::Value) -> Response {
self.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(path)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.expect("failed to make request")
}
pub fn state(&self) -> &AppState {
&self.state
}
pub fn storage(&self) -> &Arc<dyn ThingdBackend> {
&self.state.storage
}
pub fn registry(&self) -> &Arc<ToolRegistry> {
&self.state.tool_registry
}
}
pub struct TestAppBuilder {
config: AppConfig,
auth: Option<Arc<dyn Authentication>>,
storage: Option<Arc<dyn ThingdBackend>>,
registry: Option<ToolRegistry>,
}
impl TestAppBuilder {
pub fn new() -> Self {
Self {
config: AppConfig::default(),
auth: None,
storage: None,
registry: None,
}
}
pub fn with_config(mut self, config: AppConfig) -> Self {
self.config = config;
self
}
pub fn with_auth(mut self, auth: Arc<dyn Authentication>) -> Self {
self.auth = Some(auth);
self
}
pub fn with_storage(mut self, storage: Arc<dyn ThingdBackend>) -> Self {
self.storage = Some(storage);
self
}
pub fn with_registry(mut self, registry: ToolRegistry) -> Self {
self.registry = Some(registry);
self
}
pub fn build(self) -> TestApp {
let storage = self.storage.unwrap_or_else(|| {
Arc::new(MemoryThingdBackend::new())
});
let registry = self.registry.unwrap_or_else(|| {
ToolRegistry::new(
&format!("{}-test", env!("CARGO_PKG_NAME")),
env!("CARGO_PKG_VERSION"),
"Test agent",
"memory",
)
});
let state = AppState::builder()
.with_config(self.config)
.with_storage(storage)
.with_tool_registry(registry)
.build()
.expect("failed to build AppState");
let router = crate::http::create_router();
TestApp { state, router }
}
}
impl Default for TestAppBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct MockAuth {
behavior: MockAuthBehavior,
}
pub enum MockAuthBehavior {
AlwaysSuccess(AuthContext),
AlwaysFail(AuthError),
}
impl MockAuth {
pub fn always_success(subject: impl Into<String>) -> Self {
Self {
behavior: MockAuthBehavior::AlwaysSuccess(
AuthContext::new(subject, "mock"),
),
}
}
pub fn always_fail(error: AuthError) -> Self {
Self {
behavior: MockAuthBehavior::AlwaysFail(error),
}
}
pub fn always_missing() -> Self {
Self::always_fail(AuthError::Missing)
}
pub fn always_invalid() -> Self {
Self::always_fail(AuthError::Invalid)
}
}
#[axum::async_trait]
impl Authentication for MockAuth {
async fn authenticate(&self, _headers: &axum::http::HeaderMap) -> Result<AuthContext, AuthError> {
match &self.behavior {
MockAuthBehavior::AlwaysSuccess(ctx) => Ok(ctx.clone()),
MockAuthBehavior::AlwaysFail(err) => Err(err.clone()),
}
}
}
pub struct Fixtures {
storage: Arc<dyn ThingdBackend>,
}
impl Fixtures {
pub fn new(storage: Arc<dyn ThingdBackend>) -> Self {
Self { storage }
}
pub async fn create_object(
&self,
kind: &str,
id: &str,
data: serde_json::Value,
) -> Result<crate::thingd::ThingdObject, crate::core::AppError> {
self.storage.put_object(kind, id, data).await
}
pub async fn get_object(
&self,
kind: &str,
id: &str,
) -> Result<Option<serde_json::Value>, crate::core::AppError> {
let obj = self.storage.get_object(kind, id).await?;
Ok(obj.map(|o| o.data))
}
}
#[macro_export]
macro_rules! assert_response {
($response:expr, $status:expr) => {
assert_eq!($response.status(), $status, "expected status {}", $status);
};
($response:expr, $status:expr, $body:expr) => {
assert_eq!($response.status(), $status, "expected status {}", $status);
let body = axum::body::to_bytes($response.into_body(), usize::MAX)
.await
.expect("failed to read body");
let body: serde_json::Value = serde_json::from_slice(&body).expect("failed to parse JSON");
assert_eq!(body, $body);
};
}
#[macro_export]
macro_rules! assert_error {
($response:expr, $code:expr) => {
assert_eq!($response.status(), $crate::core::error::ErrorCode::$code.status_code());
let body = axum::body::to_bytes($response.into_body(), usize::MAX)
.await
.expect("failed to read body");
let body: serde_json::Value = serde_json::from_slice(&body).expect("failed to parse JSON");
assert_eq!(body["error"]["code"], stringify!($code));
};
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_testapp_builder_default() {
let app = TestApp::builder().build();
let response = app.get("/health").await;
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn test_testapp_get() {
let app = TestApp::builder().build();
let response = app.get("/health").await;
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn test_testapp_post_json() {
let app = TestApp::builder().build();
let response = app.post_json("/agent", json!({})).await;
assert_eq!(response.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn test_testapp_state() {
let app = TestApp::builder().build();
assert_eq!(app.state().config.server.port, 3000);
}
#[tokio::test]
async fn test_testapp_storage() {
let app = TestApp::builder().build();
let result = app.storage().count_objects("test").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_testapp_registry() {
let app = TestApp::builder().build();
let manifest = app.registry().generate_manifest();
assert!(manifest.name.contains("test"));
}
#[tokio::test]
async fn test_mock_auth_always_success() {
let auth = MockAuth::always_success("user-123");
let ctx = auth.authenticate(&axum::http::HeaderMap::new()).await.unwrap();
assert_eq!(ctx.subject, "user-123");
assert_eq!(ctx.adapter, "mock");
}
#[tokio::test]
async fn test_mock_auth_always_fail() {
let auth = MockAuth::always_fail(AuthError::Invalid);
let err = auth.authenticate(&axum::http::HeaderMap::new()).await.unwrap_err();
assert_eq!(err, AuthError::Invalid);
}
#[tokio::test]
async fn test_mock_auth_always_missing() {
let auth = MockAuth::always_missing();
let err = auth.authenticate(&axum::http::HeaderMap::new()).await.unwrap_err();
assert_eq!(err, AuthError::Missing);
}
#[tokio::test]
async fn test_mock_auth_always_invalid() {
let auth = MockAuth::always_invalid();
let err = auth.authenticate(&axum::http::HeaderMap::new()).await.unwrap_err();
assert_eq!(err, AuthError::Invalid);
}
#[tokio::test]
async fn test_fixtures_create_object() {
let storage = Arc::new(MemoryThingdBackend::new());
let fixtures = Fixtures::new(storage);
let obj = fixtures.create_object("user", "user-1", json!({"name": "Alice"})).await.unwrap();
assert_eq!(obj.id, "user-1");
}
#[tokio::test]
async fn test_fixtures_get_object() {
let storage = Arc::new(MemoryThingdBackend::new());
let fixtures = Fixtures::new(storage);
let obj = fixtures.create_object("user", "user-1", json!({"name": "Alice"})).await.unwrap();
let data = fixtures.get_object("user", &obj.id).await.unwrap();
assert!(data.is_some());
assert_eq!(data.unwrap()["name"], "Alice");
}
#[tokio::test]
async fn test_fixtures_get_nonexistent() {
let storage = Arc::new(MemoryThingdBackend::new());
let fixtures = Fixtures::new(storage);
let obj = fixtures.get_object("user", "nonexistent").await.unwrap();
assert!(obj.is_none());
}
}