#![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 testcontainers_modules::{postgres::Postgres, testcontainers::runners::AsyncRunner};
use crate::common::server_harness::TestServer;
fn user_schema_with_federation() -> CompiledSchema {
let json = r#"{
"types": [
{
"name": "User",
"table": "\"user\"",
"sql_source": "v_user",
"fields": [
{"name": "id", "field_type": "ID", "nullable": false},
{"name": "name", "field_type": "String", "nullable": false}
]
}
],
"queries": [
{
"name": "user",
"return_type": "User",
"returns_list": false,
"nullable": true,
"arguments": [
{"name": "id", "arg_type": "ID", "nullable": false}
]
}
],
"mutations": [],
"subscriptions": [],
"federation": {
"enabled": true,
"version": "v2",
"service_name": "users",
"entities": [
{"name": "User", "key_fields": ["id"]}
]
},
"schema_sdl": "type User {\n id: ID!\n name: String!\n}\n\ntype Query {\n user(id: ID!): User\n}\n"
}"#;
CompiledSchema::from_json(json, false).expect("test schema must be valid")
}
async fn setup_users_table(port: u16) -> tokio_postgres::Client {
let (client, conn) = tokio_postgres::connect(
&format!("host=127.0.0.1 port={port} user=testuser password=testpw dbname=testdb"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test 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 pg = Postgres::default()
.with_user("testuser")
.with_password("testpw")
.with_db_name("testdb")
.start()
.await
.expect("start postgres container");
let pg_port = pg.get_host_port_ipv4(5432).await.expect("postgres port");
let pg_client = setup_users_table(pg_port).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 db_url = format!("postgresql://testuser:testpw@127.0.0.1:{pg_port}/testdb");
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($repr: [_Any!]!) { _entities(representations: $repr) { ... on User { id name } } }",
"variables": {
"repr": [{"__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() {
use testcontainers::{GenericImage, ImageExt as _, core::WaitFor};
if std::env::var("FEDERATION_TESTS").is_err() {
eprintln!("Skipping: FEDERATION_TESTS not set");
return;
}
let pg = Postgres::default()
.with_user("testuser")
.with_password("testpw")
.with_db_name("testdb")
.start()
.await
.expect("start postgres container");
let pg_port = pg.get_host_port_ipv4(5432).await.expect("postgres port");
let pg_client = setup_users_table(pg_port).await;
pg_client
.execute(
r#"INSERT INTO "user" (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"#,
&[&"user-bob", &"Bob"],
)
.await
.expect("insert test user");
let db_url = format!("postgresql://testuser:testpw@127.0.0.1:{pg_port}/testdb");
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 supergraph =
include_str!("../../fraiseql-core/tests/federation/fixtures/supergraph_single.graphql")
.replace("__SUBGRAPH_URL__", &format!("{}/graphql", server.url));
let supergraph_file = tempfile::NamedTempFile::new().expect("tmpfile");
std::fs::write(supergraph_file.path(), &supergraph).expect("write supergraph");
let _router = GenericImage::new("ghcr.io/apollographql/router", "v1.45.0")
.with_wait_for(WaitFor::message_on_stderr("GraphQL endpoint exposed"))
.with_network("host")
.with_env_var("APOLLO_ROUTER_SUPERGRAPH_PATH", "/supergraph.graphql")
.start()
.await
.expect("start Apollo Router container");
let _ = supergraph_file;
let http = Client::new();
let resp = http
.post("http://127.0.0.1:4000/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]
#[ignore = "requires Docker + PostgreSQL (FRAISEQL_FEDERATION_E2E=1)"]
async fn cross_subgraph_entity_resolution_e2e() {
if std::env::var("FRAISEQL_FEDERATION_E2E").is_err() {
eprintln!("Skipping: FRAISEQL_FEDERATION_E2E not set");
return;
}
let pg_a = Postgres::default()
.with_user("testuser")
.with_password("testpw")
.with_db_name("testdb")
.start()
.await
.expect("start postgres A");
let pg_a_port = pg_a.get_host_port_ipv4(5432).await.expect("pg A port");
let pg_a_client = setup_users_table(pg_a_port).await;
pg_a_client
.execute(
r#"INSERT INTO "user" (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"#,
&[&"user-1", &"Alice"],
)
.await
.expect("insert user into subgraph A");
let db_url_a = format!("postgresql://testuser:testpw@127.0.0.1:{pg_a_port}/testdb");
let adapter_a = Arc::new(PostgresAdapter::new(&db_url_a).await.expect("pg adapter A"));
let schema_a = user_schema_with_federation();
let server_a = TestServer::start(schema_a, adapter_a).await;
let pg_b = Postgres::default()
.with_user("testuser")
.with_password("testpw")
.with_db_name("testdb")
.start()
.await
.expect("start postgres B");
let pg_b_port = pg_b.get_host_port_ipv4(5432).await.expect("pg B port");
let pg_b_client = {
let (client, conn) = tokio_postgres::connect(
&format!("host=127.0.0.1 port={pg_b_port} user=testuser password=testpw dbname=testdb"),
tokio_postgres::NoTls,
)
.await
.expect("connect to pg B");
tokio::spawn(async move { conn.await.ok() });
client
};
pg_b_client
.batch_execute(
r#"CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
review_count INTEGER NOT NULL DEFAULT 0,
__typename TEXT DEFAULT 'User'
);"#,
)
.await
.expect("create user table on B");
pg_b_client
.execute(
r#"INSERT INTO "user" (id, review_count) VALUES ($1, $2) ON CONFLICT DO NOTHING"#,
&[&"user-1", &42i32],
)
.await
.expect("insert review data into subgraph B");
let schema_b_json = r#"{
"types": [
{
"name": "User",
"table": "\"user\"",
"sql_source": "\"user\"",
"fields": [
{"name": "id", "field_type": "ID", "nullable": false},
{"name": "reviewCount", "field_type": "Int", "nullable": false, "column": "review_count"}
]
}
],
"queries": [],
"mutations": [],
"subscriptions": [],
"federation": {
"enabled": true,
"version": "v2",
"service_name": "reviews",
"entities": [
{"name": "User", "key_fields": ["id"]}
]
},
"schema_sdl": "extend type User @key(fields: \"id\") {\n id: ID! @external\n reviewCount: Int!\n}\n"
}"#;
let schema_b = CompiledSchema::from_json(schema_b_json, false).expect("schema B valid");
let db_url_b = format!("postgresql://testuser:testpw@127.0.0.1:{pg_b_port}/testdb");
let adapter_b = Arc::new(PostgresAdapter::new(&db_url_b).await.expect("pg adapter B"));
let server_b = TestServer::start(schema_b, adapter_b).await;
let http = Client::new();
let sdl_a = http
.post(format!("{}/graphql", server_a.url))
.json(&json!({"query": "{ _service { sdl } }"}))
.send()
.await
.expect("SDL A")
.json::<Value>()
.await
.expect("parse A");
assert!(
sdl_a["data"]["_service"]["sdl"].is_string(),
"Subgraph A should serve SDL: {sdl_a}"
);
let sdl_b = http
.post(format!("{}/graphql", server_b.url))
.json(&json!({"query": "{ _service { sdl } }"}))
.send()
.await
.expect("SDL B")
.json::<Value>()
.await
.expect("parse B");
assert!(
sdl_b["data"]["_service"]["sdl"].is_string(),
"Subgraph B should serve SDL: {sdl_b}"
);
let entities_a = http
.post(format!("{}/graphql", server_a.url))
.json(&json!({
"query": "query($repr: [_Any!]!) { _entities(representations: $repr) { ... on User { id name } } }",
"variables": {"repr": [{"__typename": "User", "id": "user-1"}]}
}))
.send()
.await
.expect("entities A")
.json::<Value>()
.await
.expect("parse entities A");
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 = http
.post(format!("{}/graphql", server_b.url))
.json(&json!({
"query": "query($repr: [_Any!]!) { _entities(representations: $repr) { ... on User { id reviewCount } } }",
"variables": {"repr": [{"__typename": "User", "id": "user-1"}]}
}))
.send()
.await
.expect("entities B")
.json::<Value>()
.await
.expect("parse entities B");
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]
);
}