#![allow(clippy::unwrap_used)] #![allow(missing_docs)] #![allow(clippy::missing_panics_doc)]
mod common;
use std::sync::Arc;
use axum::{Router, body::Body, routing::get};
use fraiseql_core::{runtime::Executor, schema::CompiledSchema};
use fraiseql_server::{
routes::{
api::{metadata::metadata_handler, usage::usage_handler},
graphql::AppState,
},
usage::{aggregator::UsageAggregator, events::MutationAuditEvent},
};
use fraiseql_test_utils::failing_adapter::FailingAdapter;
use http::{Request, StatusCode};
use tower::ServiceExt;
fn make_state() -> AppState<FailingAdapter> {
let schema = CompiledSchema::new();
let adapter = Arc::new(FailingAdapter::new());
AppState::new(Arc::new(Executor::new(schema, adapter)))
}
fn make_state_with_usage(usage: Arc<UsageAggregator>) -> AppState<FailingAdapter> {
make_state().with_usage(usage)
}
async fn get_json(router: &Router, uri: &str) -> (StatusCode, serde_json::Value) {
let response = router
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn test_metadata_endpoint_returns_200_with_envelope() {
let router = Router::new()
.route("/api/v1/schema/metadata", get(metadata_handler::<FailingAdapter>))
.with_state(make_state());
let (status, body) = get_json(&router, "/api/v1/schema/metadata").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "success");
assert!(body["data"]["metadata"].is_object(), "data.metadata must be an object");
}
#[tokio::test]
async fn test_metadata_endpoint_empty_schema_returns_empty_map() {
let router = Router::new()
.route("/api/v1/schema/metadata", get(metadata_handler::<FailingAdapter>))
.with_state(make_state());
let (status, body) = get_json(&router, "/api/v1/schema/metadata").await;
assert_eq!(status, StatusCode::OK);
let metadata = &body["data"]["metadata"];
assert_eq!(
metadata.as_object().unwrap().len(),
0,
"empty schema should produce empty metadata map"
);
}
#[tokio::test]
async fn test_metadata_endpoint_accessible_without_auth_by_default() {
let router = Router::new()
.route("/api/v1/schema/metadata", get(metadata_handler::<FailingAdapter>))
.with_state(make_state());
let (status, _) = get_json(&router, "/api/v1/schema/metadata").await;
assert_eq!(status, StatusCode::OK, "metadata must be reachable without auth by default");
}
fn make_usage_router(usage: Arc<UsageAggregator>) -> Router {
Router::new()
.route("/api/v1/admin/usage", get(usage_handler::<FailingAdapter>))
.with_state(make_state_with_usage(usage))
}
#[tokio::test]
async fn test_usage_endpoint_empty_aggregator_returns_empty_mutations() {
let router = make_usage_router(Arc::new(UsageAggregator::new()));
let (status, body) =
get_json(&router, "/api/v1/admin/usage?tenant_id=acme&period=2026-05").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["tenant_id"], "acme");
assert_eq!(body["period"], "2026-05");
let mutations = &body["usage"]["mutations"];
assert!(mutations.is_object());
assert_eq!(mutations.as_object().unwrap().len(), 0);
}
#[tokio::test]
async fn test_usage_endpoint_reflects_recorded_events() {
let usage = Arc::new(UsageAggregator::new());
for _ in 0..5 {
usage.record(&MutationAuditEvent::new(
"create_user",
"User",
"create",
"tenant_a",
"2026-05",
));
}
for _ in 0..3 {
usage.record(&MutationAuditEvent::new(
"create_order",
"Order",
"create",
"tenant_a",
"2026-05",
));
}
let router = make_usage_router(Arc::clone(&usage));
let (status, body) =
get_json(&router, "/api/v1/admin/usage?tenant_id=tenant_a&period=2026-05").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["usage"]["mutations"]["User"], 5);
assert_eq!(body["usage"]["mutations"]["Order"], 3);
}
#[tokio::test]
async fn test_usage_endpoint_tenant_isolation() {
let usage = Arc::new(UsageAggregator::new());
usage.record(&MutationAuditEvent::new("create_user", "User", "create", "tenant_a", "2026-05"));
let router = make_usage_router(Arc::clone(&usage));
let (status, body) =
get_json(&router, "/api/v1/admin/usage?tenant_id=tenant_b&period=2026-05").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["usage"]["mutations"].as_object().unwrap().len(), 0);
}
#[tokio::test]
async fn test_usage_endpoint_rejects_invalid_period() {
let router = make_usage_router(Arc::new(UsageAggregator::new()));
let (status, body) =
get_json(&router, "/api/v1/admin/usage?tenant_id=acme&period=invalid").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].is_string(), "error response must have an 'error' field");
}
#[tokio::test]
async fn test_audit_layer_records_tracing_events() {
use fraiseql_server::usage::{aggregator::UsageAggregator, layer::MutationAuditLayer};
use tracing_subscriber::{Registry, layer::SubscriberExt};
let aggregator = Arc::new(UsageAggregator::new());
let layer = MutationAuditLayer::new(Arc::clone(&aggregator));
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
target: "fraiseql::mutation_audit",
mutation_name = "create_user",
entity_type = "User",
operation = "create",
tenant_id = "audit_tenant",
period = "2026-05",
);
});
let summary = aggregator.query("audit_tenant", "2026-05");
assert_eq!(
summary.mutations.get("User").copied(),
Some(1),
"MutationAuditLayer should have captured the tracing event"
);
}
#[tokio::test]
async fn test_audit_layer_ignores_non_mutation_events() {
use fraiseql_server::usage::{aggregator::UsageAggregator, layer::MutationAuditLayer};
use tracing_subscriber::{Registry, layer::SubscriberExt};
let aggregator = Arc::new(UsageAggregator::new());
let layer = MutationAuditLayer::new(Arc::clone(&aggregator));
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
target: "app::some_other_target",
mutation_name = "create_user",
entity_type = "User",
operation = "create",
tenant_id = "ignored_tenant",
period = "2026-05",
);
});
let summary = aggregator.query("ignored_tenant", "2026-05");
assert_eq!(
summary.mutations.get("User").copied(),
None,
"wrong-target events must not be counted"
);
}
#[tokio::test]
async fn test_audit_pipeline_emit_then_query_via_http() {
use fraiseql_server::usage::{aggregator::UsageAggregator, layer::MutationAuditLayer};
use tracing_subscriber::{Registry, layer::SubscriberExt};
let aggregator = Arc::new(UsageAggregator::new());
let layer = MutationAuditLayer::new(Arc::clone(&aggregator));
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
for _ in 0..2_u8 {
tracing::info!(
target: "fraiseql::mutation_audit",
mutation_name = "create_product",
entity_type = "Product",
operation = "create",
tenant_id = "shop",
period = "2026-05",
);
}
});
let router = make_usage_router(Arc::clone(&aggregator));
let (status, body) =
get_json(&router, "/api/v1/admin/usage?tenant_id=shop&period=2026-05").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["usage"]["mutations"]["Product"], 2);
}
#[cfg(feature = "federation")]
mod federation_plan_tests {
use fraiseql_server::routes::api::federation::plan_handler;
use super::*;
#[tokio::test]
async fn test_federation_plan_endpoint_returns_200() {
let router = Router::new()
.route("/admin/v1/federation/plan", get(plan_handler::<FailingAdapter>))
.with_state(make_state());
let query = urlencoding::encode("{ __typename }");
let uri = format!("/admin/v1/federation/plan?query={query}");
let (status, body) = get_json(&router, &uri).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "success");
assert!(body["data"].is_object(), "response must contain a data object");
}
#[tokio::test]
async fn test_federation_plan_endpoint_missing_query_returns_400() {
let router = Router::new()
.route("/admin/v1/federation/plan", get(plan_handler::<FailingAdapter>))
.with_state(make_state());
let (status, _body) = get_json(&router, "/admin/v1/federation/plan").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
}