#![allow(clippy::print_stdout, clippy::print_stderr)] mod common;
use std::sync::Arc;
use fraiseql_core::{db::postgres::PostgresAdapter, schema::CompiledSchema};
use fraiseql_test_utils::failing_adapter::FailingAdapter;
use reqwest::Client;
use serde_json::{Value, json};
use crate::common::server_harness::TestServer;
fn env_opt(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
async fn post_gql(http: &Client, base: &str, body: Value) -> Value {
http.post(format!("{base}/graphql"))
.json(&body)
.send()
.await
.expect("graphql request failed")
.json::<Value>()
.await
.expect("json parse failed")
}
fn user_schema_with_federation() -> CompiledSchema {
let json = include_str!("fixtures/federation/schema_users.json");
CompiledSchema::from_json(json, false).expect("test schema must be valid")
}
async fn setup_users_table(db_url: &str) -> tokio_postgres::Client {
let (client, conn) = tokio_postgres::connect(db_url, tokio_postgres::NoTls)
.await
.expect("connect to harness postgres");
tokio::spawn(async move { conn.await.ok() });
client
.batch_execute(
r#"CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
__typename TEXT DEFAULT 'User'
);"#,
)
.await
.expect("create user table");
client
}
#[tokio::test]
async fn service_sdl_contains_federation_directives() {
let schema = user_schema_with_federation();
let adapter = Arc::new(FailingAdapter::new());
let server = TestServer::start(schema, adapter).await;
let client = Client::new();
let resp = client
.post(format!("{}/graphql", server.url))
.json(&json!({"query": "{ _service { sdl } }"}))
.send()
.await
.expect("request failed")
.json::<Value>()
.await
.expect("json parse failed");
assert!(resp["errors"].is_null(), "unexpected errors: {}", resp["errors"]);
let sdl = resp["data"]["_service"]["sdl"].as_str().expect("_service.sdl must be a string");
assert!(
sdl.contains("@key(fields: \"id\")"),
"SDL must contain inline @key directive, got:\n{sdl}"
);
assert!(sdl.contains("type User"), "SDL must define User type");
assert!(sdl.contains("_entities"), "SDL must declare _entities query");
assert!(sdl.contains("_service"), "SDL must declare _service query");
assert!(!sdl.contains("# @key"), "SDL must not contain commented @key: {sdl}");
}
#[tokio::test]
async fn entities_resolves_user_by_id() {
if std::env::var("FEDERATION_TESTS").is_err() {
eprintln!("Skipping: FEDERATION_TESTS not set");
return;
}
let Some(pg) = fraiseql_test_support::postgres().await else {
eprintln!("Skipping: no DATABASE_URL (or local-testcontainers)");
return;
};
let db_url = pg.url();
let pg_client = setup_users_table(db_url).await;
pg_client
.execute(
r#"INSERT INTO "user" (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"#,
&[&"user-alice", &"Alice"],
)
.await
.expect("insert test user");
let adapter = Arc::new(PostgresAdapter::new(db_url).await.expect("postgres adapter"));
let schema = user_schema_with_federation();
let server = TestServer::start(schema, adapter).await;
let http = Client::new();
let resp = http
.post(format!("{}/graphql", server.url))
.json(&json!({
"query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { id name } } }",
"variables": {
"representations": [{"__typename": "User", "id": "user-alice"}]
}
}))
.send()
.await
.expect("request failed")
.json::<Value>()
.await
.expect("json parse failed");
assert!(resp["errors"].is_null(), "unexpected errors: {}", resp["errors"]);
assert_eq!(resp["data"]["_entities"][0]["name"], "Alice", "full response: {resp}");
}
#[tokio::test]
async fn apollo_router_routes_query_to_fraiseql_subgraph() {
let Some(router_url) = env_opt("ROUTER_URL") else {
eprintln!("Skipping: ROUTER_URL not set (Apollo Router endpoint)");
return;
};
let http = Client::new();
let resp = http
.post(format!("{router_url}/graphql"))
.json(&json!({"query": "{ user(id: \"user-bob\") { id name } }"}))
.send()
.await
.expect("gateway request failed")
.json::<Value>()
.await
.expect("json parse failed");
assert!(resp["errors"].is_null(), "gateway errors: {}", resp["errors"]);
assert_eq!(resp["data"]["user"]["name"], "Bob");
}
#[tokio::test]
async fn cross_subgraph_entity_resolution_e2e() {
let (Some(subgraph_a), Some(subgraph_b)) =
(env_opt("SUBGRAPH_A_URL"), env_opt("SUBGRAPH_B_URL"))
else {
eprintln!("Skipping: SUBGRAPH_A_URL / SUBGRAPH_B_URL not set");
return;
};
let http = Client::new();
let sdl_a = post_gql(&http, &subgraph_a, json!({"query": "{ _service { sdl } }"})).await;
assert!(
sdl_a["data"]["_service"]["sdl"].is_string(),
"Subgraph A should serve SDL: {sdl_a}"
);
let sdl_b = post_gql(&http, &subgraph_b, json!({"query": "{ _service { sdl } }"})).await;
assert!(
sdl_b["data"]["_service"]["sdl"].is_string(),
"Subgraph B should serve SDL: {sdl_b}"
);
let entities_a = post_gql(
&http,
&subgraph_a,
json!({
"query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { id name } } }",
"variables": {"representations": [{"__typename": "User", "id": "user-1"}]}
}),
)
.await;
assert!(
entities_a["errors"].is_null(),
"Subgraph A entity errors: {}",
entities_a["errors"]
);
assert_eq!(entities_a["data"]["_entities"][0]["name"], "Alice");
assert_eq!(entities_a["data"]["_entities"][0]["id"], "user-1");
let entities_b = post_gql(
&http,
&subgraph_b,
json!({
"query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { id reviewcount } } }",
"variables": {"representations": [{"__typename": "User", "id": "user-1"}]}
}),
)
.await;
assert!(
entities_b["errors"].is_null(),
"Subgraph B entity errors: {}",
entities_b["errors"]
);
assert_eq!(entities_b["data"]["_entities"][0]["reviewcount"], 42);
assert_eq!(entities_b["data"]["_entities"][0]["id"], "user-1");
let user_a = &entities_a["data"]["_entities"][0];
let user_b = &entities_b["data"]["_entities"][0];
assert_eq!(user_a["id"], user_b["id"], "merge key must match");
let merged = json!({
"id": user_a["id"],
"name": user_a["name"],
"reviewcount": user_b["reviewcount"],
});
assert_eq!(merged["name"], "Alice");
assert_eq!(merged["reviewcount"], 42);
eprintln!("Cross-subgraph merge successful: {merged}");
}
#[tokio::test]
async fn entities_returns_null_for_missing_entity() {
let schema = user_schema_with_federation();
let adapter = Arc::new(FailingAdapter::new());
let server = TestServer::start(schema, adapter).await;
let http = Client::new();
let resp = http
.post(format!("{}/graphql", server.url))
.json(&json!({
"query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { id } } }",
"variables": {
"representations": [{"__typename": "User", "id": "00000000-0000-0000-0000-000000000000"}]
}
}))
.send()
.await
.expect("request failed")
.json::<Value>()
.await
.expect("json parse failed");
assert!(resp["errors"].is_null(), "unexpected errors: {}", resp["errors"]);
assert!(
resp["data"]["_entities"][0].is_null(),
"missing entity should be null, got: {}",
resp["data"]["_entities"][0]
);
}