#![allow(clippy::unwrap_used, clippy::panic)] #![allow(clippy::wildcard_imports)]
mod analyze_tests {
use super::super::analyze::*;
#[test]
fn test_analyze_nonexistent_file() {
let result = run("/nonexistent/schema.json");
assert!(result.is_err(), "expected Err for nonexistent schema file, got: {result:?}");
}
}
mod cost_tests {
use super::super::cost::*;
#[test]
fn test_cost_simple_query() {
let query = "query { users { id } }";
let result = run(query);
let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok for simple query: {e}"));
assert_eq!(cmd_result.status, "success");
}
#[test]
fn test_cost_invalid_query_fails() {
let query = "query { invalid {";
let result = run(query);
assert!(result.is_err(), "expected Err for invalid query, got: {result:?}");
}
#[test]
fn test_cost_provides_score() {
let query = "query { users { id name } }";
let result = run(query);
let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok for score query: {e}"));
let data = cmd_result.data.expect("`cost` must return a payload, not None");
assert!(
data["complexity_score"].is_number(),
"complexity_score must be a number, got: {data}"
);
}
#[test]
fn test_cost_more_fields_higher_score() {
let few_fields = run("query { users { id } }").unwrap();
let many_fields = run("query { users { id name email phone address } }").unwrap();
let few_score = few_fields
.data
.as_ref()
.and_then(|d| d["complexity_score"].as_u64())
.unwrap_or(0);
let many_score = many_fields
.data
.as_ref()
.and_then(|d| d["complexity_score"].as_u64())
.unwrap_or(0);
assert!(many_score >= few_score);
}
#[test]
fn test_cost_nested_has_higher_score() {
let shallow = run("query { users { id } }").unwrap();
let deep = run("query { users { posts { comments { author } } } }").unwrap();
let shallow_score =
shallow.data.as_ref().and_then(|d| d["complexity_score"].as_u64()).unwrap_or(0);
let deep_score =
deep.data.as_ref().and_then(|d| d["complexity_score"].as_u64()).unwrap_or(0);
assert!(deep_score > shallow_score);
}
}
mod compile_tests {
use std::collections::HashMap;
use fraiseql_core::schema::{
ArgumentDefinition, AutoParams, CompiledSchema, FieldDefinition, FieldDenyPolicy,
FieldType, InputFieldDefinition, InputObjectDefinition, InputStyle, MutationDefinition,
MutationOperation, NamingConvention, QueryDefinition, TypeDefinition,
};
use super::super::compile::{
EMIT_DDL_FORMAT, WIDE_FANOUT_THRESHOLD, apply_database_report, emit_ddl_to_dir,
field_type_to_pg, infer_native_columns_from_arg_types, jsonb_preserve_mismatches,
to_snake_case, wide_cascade_mutations,
};
use crate::schema::{
SchemaValidator,
intermediate::{
IntermediateField, IntermediateQuery, IntermediateSchema, IntermediateType,
},
};
fn mutation_with_fanout(
name: &str,
views: &[&str],
fact_tables: &[&str],
) -> MutationDefinition {
let mut m = MutationDefinition::new(name, "SomeResult");
m.invalidates_views = views.iter().map(|s| (*s).to_string()).collect();
m.invalidates_fact_tables = fact_tables.iter().map(|s| (*s).to_string()).collect();
m
}
#[test]
fn test_wide_cascade_below_threshold_not_flagged() {
let schema = CompiledSchema {
mutations: vec![mutation_with_fanout("update", &["tv_user", "tv_post"], &[])],
..Default::default()
};
assert!(
wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty(),
"2 targets is below threshold of 3"
);
}
#[test]
fn test_wide_cascade_at_threshold_flagged() {
let schema = CompiledSchema {
mutations: vec![mutation_with_fanout(
"updateUserWithPosts",
&["tv_user", "tv_post", "tv_comment"],
&[],
)],
..Default::default()
};
let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].name, "updateUserWithPosts");
}
#[test]
fn test_wide_cascade_views_plus_fact_tables_counted_together() {
let schema = CompiledSchema {
mutations: vec![mutation_with_fanout(
"createOrder",
&["tv_order", "tv_order_item"],
&["tf_sales"],
)],
..Default::default()
};
let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
assert_eq!(flagged.len(), 1, "2 views + 1 fact table = 3 total, meets threshold");
}
#[test]
fn test_wide_cascade_only_wide_mutations_flagged() {
let schema = CompiledSchema {
mutations: vec![
mutation_with_fanout("narrow", &["tv_user"], &[]),
mutation_with_fanout("wide", &["tv_user", "tv_post", "tv_comment"], &[]),
],
..Default::default()
};
let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].name, "wide");
}
#[test]
fn test_wide_cascade_no_mutations_no_warnings() {
let schema = CompiledSchema::default();
assert!(wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty());
}
fn schema_with_input_mutation(
naming: NamingConvention,
input_style: InputStyle,
operation: MutationOperation,
input_field_names: &[&str],
) -> CompiledSchema {
let mut schema = CompiledSchema {
naming_convention: naming,
..Default::default()
};
schema.input_types.push(InputObjectDefinition {
name: "CreateOrderInput".to_string(),
fields: input_field_names
.iter()
.map(|n| InputFieldDefinition::new(*n, "String"))
.collect(),
description: None,
metadata: None,
});
let mut m = MutationDefinition::new("createOrder", "Order");
m.input_style = input_style;
m.operation = operation;
m.arguments = vec![ArgumentDefinition {
name: "input".to_string(),
arg_type: FieldType::Input("CreateOrderInput".to_string()),
nullable: false,
default_value: None,
description: None,
deprecation: None,
localized: false,
}];
schema.mutations.push(m);
schema
}
fn schema_with_object_input_mutation(
naming: NamingConvention,
input_style: InputStyle,
operation: MutationOperation,
input_field_names: &[&str],
) -> CompiledSchema {
let mut schema =
schema_with_input_mutation(naming, input_style, operation, input_field_names);
schema.mutations[0].arguments[0].arg_type =
FieldType::Object("CreateOrderInput".to_string());
schema
}
#[test]
fn jsonb_preserve_flags_object_typed_input_arg() {
let schema = schema_with_object_input_mutation(
NamingConvention::Preserve,
InputStyle::Jsonb,
MutationOperation::Insert { table: "t".into() },
&["shippingAddress"],
);
let hits = jsonb_preserve_mismatches(&schema);
assert_eq!(hits.len(), 1, "Object-typed input arg must be detected");
assert_eq!(hits[0].1, vec!["shippingAddress"]);
}
#[test]
fn jsonb_preserve_flags_camelcase_fields_under_preserve() {
let schema = schema_with_input_mutation(
NamingConvention::Preserve,
InputStyle::Jsonb,
MutationOperation::Insert { table: "t".into() },
&["shippingAddress", "customerNote"],
);
let hits = jsonb_preserve_mismatches(&schema);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, "createOrder");
assert_eq!(hits[0].1, vec!["shippingAddress", "customerNote"]);
}
#[test]
fn jsonb_preserve_flags_update_operation_too() {
let schema = schema_with_input_mutation(
NamingConvention::Preserve,
InputStyle::Flatten,
MutationOperation::Update { table: "t".into() },
&["fullName"],
);
let hits = jsonb_preserve_mismatches(&schema);
assert_eq!(hits.len(), 1, "Update is single-JSONB and must be flagged");
assert_eq!(hits[0].1, vec!["fullName"]);
}
#[test]
fn jsonb_preserve_silent_when_naming_is_camel_case() {
let schema = schema_with_input_mutation(
NamingConvention::CamelCase,
InputStyle::Jsonb,
MutationOperation::Insert { table: "t".into() },
&["shippingAddress"],
);
assert!(jsonb_preserve_mismatches(&schema).is_empty());
}
#[test]
fn jsonb_preserve_silent_for_snake_case_fields() {
let schema = schema_with_input_mutation(
NamingConvention::Preserve,
InputStyle::Jsonb,
MutationOperation::Insert { table: "t".into() },
&["shipping_address", "customer_note"],
);
assert!(jsonb_preserve_mismatches(&schema).is_empty());
}
#[test]
fn jsonb_preserve_silent_for_flatten_insert() {
let schema = schema_with_input_mutation(
NamingConvention::Preserve,
InputStyle::Flatten,
MutationOperation::Insert { table: "t".into() },
&["shippingAddress"],
);
assert!(jsonb_preserve_mismatches(&schema).is_empty());
}
#[test]
fn validate_accepts_a_query_returning_a_declared_type() {
let schema = IntermediateSchema {
types: vec![IntermediateType {
name: "User".into(),
fields: vec![IntermediateField {
function: None,
deprecated: None,
vector_config: None,
vector_distance: None,
name: "id".into(),
field_type: "ID".into(),
nullable: false,
description: None,
directives: None,
requires_scope: None,
on_deny: None,
authorize: None,
hierarchy: None,
unique: None,
localized: false,
}],
sql_source: Some("v_user".into()),
..Default::default()
}],
queries: vec![IntermediateQuery {
name: "users".into(),
return_type: "User".into(),
returns_list: true,
sql_source: Some("v_user".into()),
..Default::default()
}],
..Default::default()
};
let report = SchemaValidator::validate(&schema)
.unwrap_or_else(|e| panic!("validation should not have errored: {e}"));
assert!(
report.errors.is_empty(),
"a query returning a declared type must validate clean, got: {:?}",
report.errors
);
}
#[test]
fn validate_rejects_a_query_returning_an_undeclared_type() {
let schema = IntermediateSchema {
types: vec![],
queries: vec![IntermediateQuery {
name: "users".into(),
return_type: "UnknownType".into(),
returns_list: true,
sql_source: Some("v_user".into()),
..Default::default()
}],
..Default::default()
};
let report = SchemaValidator::validate(&schema)
.unwrap_or_else(|e| panic!("validation should report, not error: {e}"));
assert!(
!report.errors.is_empty(),
"a query returning an undeclared type must be reported"
);
assert!(
report.errors.iter().any(|e| format!("{e:?}").contains("UnknownType")),
"the diagnostic must name the undeclared type, got: {:?}",
report.errors
);
}
fn make_query(
name: &str,
sql_source: Option<&str>,
jsonb_column: &str,
args: Vec<(&str, FieldType)>,
native_columns: std::collections::HashMap<String, fraiseql_core::schema::NativeColumn>,
) -> QueryDefinition {
QueryDefinition {
name: name.to_string(),
return_type: "T".to_string(),
returns_list: false,
nullable: true,
arguments: args.into_iter().map(|(n, t)| ArgumentDefinition::new(n, t)).collect(),
sql_source: sql_source.map(str::to_string),
jsonb_column: jsonb_column.to_string(),
native_columns,
auto_params: AutoParams::default(),
..Default::default()
}
}
#[test]
fn a_sharpened_pagination_order_is_folded_into_the_query() {
use fraiseql_core::schema::PaginationOrder;
let mut schema = CompiledSchema {
queries: vec![QueryDefinition {
pagination_order: Some(PaginationOrder::Identity),
..make_query("users", Some("v_user"), "data", vec![], HashMap::new())
}],
..Default::default()
};
let report = crate::schema::database_validator::DatabaseValidationReport {
warnings: Vec::new(),
native_columns: HashMap::new(),
pagination_orders: std::iter::once((
"users".to_string(),
PaginationOrder::Column("pk_user".into()),
))
.collect(),
};
apply_database_report(&mut schema, &report);
assert_eq!(
schema.queries[0].pagination_order,
Some(PaginationOrder::Column("pk_user".into()))
);
}
#[test]
fn a_query_absent_from_the_report_keeps_its_offline_order() {
use fraiseql_core::schema::PaginationOrder;
let mut schema = CompiledSchema {
queries: vec![QueryDefinition {
pagination_order: Some(PaginationOrder::Identity),
..make_query("users", Some("v_user"), "data", vec![], HashMap::new())
}],
..Default::default()
};
let report = crate::schema::database_validator::DatabaseValidationReport {
warnings: Vec::new(),
native_columns: HashMap::new(),
pagination_orders: HashMap::new(),
};
apply_database_report(&mut schema, &report);
assert_eq!(
schema.queries[0].pagination_order,
Some(PaginationOrder::Identity),
"the offline answer is correct; an empty report is not a reason to drop it"
);
}
#[test]
fn test_infer_id_arg_becomes_uuid_native_column() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
Some("tv_user"),
"data",
vec![("id", FieldType::Id)],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert_eq!(
schema.queries[0].native_columns.get("id").map(|c| c.pg_type.as_str()),
Some("uuid"),
"ID-typed arg should be inferred as uuid native column"
);
}
#[test]
fn a_function_roots_id_argument_infers_no_native_column() {
let mut root = make_query(
"lineItems",
Some("fn_line_items"),
"data",
vec![("assetId", FieldType::Id)],
std::collections::HashMap::new(),
);
root.sql_source_kind = fraiseql_core::schema::SourceKind::Function;
let mut schema = CompiledSchema {
queries: vec![root],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert!(schema.queries[0].native_columns.is_empty());
}
#[test]
fn test_infer_uuid_arg_becomes_uuid_native_column() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
Some("tv_user"),
"data",
vec![("userId", FieldType::Uuid)],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert_eq!(
schema.queries[0].native_columns.get("userId").map(|c| c.pg_type.as_str()),
Some("uuid")
);
}
#[test]
fn test_infer_does_not_override_explicit_declaration() {
let mut explicit = std::collections::HashMap::new();
explicit.insert("id".to_string(), fraiseql_core::schema::NativeColumn::nullable("text"));
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
Some("tv_user"),
"data",
vec![("id", FieldType::Id)],
explicit,
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert_eq!(
schema.queries[0].native_columns.get("id").map(|c| c.pg_type.as_str()),
Some("text"),
"explicit native_columns declaration must win over inference"
);
}
#[test]
fn test_infer_skips_queries_without_sql_source() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
None,
"data",
vec![("id", FieldType::Id)],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert!(
schema.queries[0].native_columns.is_empty(),
"queries without sql_source must not get inferred native_columns"
);
}
#[test]
fn test_infer_skips_queries_without_jsonb_column() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
Some("v_user"),
"",
vec![("id", FieldType::Id)],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert!(
schema.queries[0].native_columns.is_empty(),
"queries without jsonb_column must not get inferred native_columns"
);
}
#[test]
fn test_infer_skips_non_id_types() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"user",
Some("tv_user"),
"data",
vec![("username", FieldType::String), ("age", FieldType::Int)],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert!(
schema.queries[0].native_columns.is_empty(),
"String/Int args must not be inferred as native columns"
);
}
#[test]
fn test_infer_skips_auto_param_names() {
let mut schema = CompiledSchema {
queries: vec![make_query(
"users",
Some("tv_user"),
"data",
vec![
("where", FieldType::Id),
("limit", FieldType::Id),
("orderBy", FieldType::Id),
],
std::collections::HashMap::new(),
)],
..Default::default()
};
infer_native_columns_from_arg_types(&mut schema);
assert!(
schema.queries[0].native_columns.is_empty(),
"auto-param names must never be inferred as native columns even if typed ID"
);
}
#[test]
fn test_to_snake_case_pascal() {
assert_eq!(to_snake_case("UserProfile"), "user_profile");
}
#[test]
fn test_to_snake_case_single_word() {
assert_eq!(to_snake_case("User"), "user");
}
#[test]
fn test_to_snake_case_already_lower() {
assert_eq!(to_snake_case("user"), "user");
}
#[test]
fn test_field_type_to_pg_scalar_types() {
assert_eq!(field_type_to_pg(&FieldType::String), "TEXT");
assert_eq!(field_type_to_pg(&FieldType::Int), "INTEGER");
assert_eq!(field_type_to_pg(&FieldType::Float), "DOUBLE PRECISION");
assert_eq!(field_type_to_pg(&FieldType::Boolean), "BOOLEAN");
assert_eq!(field_type_to_pg(&FieldType::Id), "UUID");
assert_eq!(field_type_to_pg(&FieldType::Uuid), "UUID");
assert_eq!(field_type_to_pg(&FieldType::DateTime), "TIMESTAMPTZ");
assert_eq!(field_type_to_pg(&FieldType::Date), "DATE");
assert_eq!(field_type_to_pg(&FieldType::Time), "TIME");
assert_eq!(field_type_to_pg(&FieldType::Json), "JSONB");
assert_eq!(field_type_to_pg(&FieldType::Decimal), "NUMERIC");
assert_eq!(field_type_to_pg(&FieldType::Vector), "VECTOR");
}
#[test]
fn test_field_type_to_pg_enum_uses_type_name() {
assert_eq!(field_type_to_pg(&FieldType::Enum("StatusEnum".to_string())), "StatusEnum");
}
#[test]
fn test_field_type_to_pg_complex_types_are_jsonb() {
assert_eq!(field_type_to_pg(&FieldType::Json), "JSONB");
assert_eq!(field_type_to_pg(&FieldType::Object("Address".to_string())), "JSONB");
assert_eq!(field_type_to_pg(&FieldType::list(FieldType::String)), "JSONB");
}
#[test]
fn test_emit_ddl_to_dir_creates_files() {
let tmp = tempfile::tempdir().unwrap();
let schema = CompiledSchema {
types: vec![TypeDefinition {
name: "UserProfile".into(),
fields: vec![
FieldDefinition {
name: "id".into(),
field_type: FieldType::Id,
nullable: false,
default_value: None,
description: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
localized: false,
},
FieldDefinition {
name: "email".into(),
field_type: FieldType::String,
nullable: true,
default_value: None,
description: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
localized: false,
},
],
description: Some("Test type".to_string()),
sql_source: "tv_user_profile".into(),
jsonb_column: "data".to_string(),
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: vec![],
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}],
..Default::default()
};
let dir = tmp.path().to_str().unwrap();
emit_ddl_to_dir(&schema, dir).unwrap();
let ddl_file = tmp.path().join("user_profile.sql");
assert!(ddl_file.exists(), "user_profile.sql must be created");
let content = std::fs::read_to_string(ddl_file).unwrap();
assert!(
content.contains("CREATE TABLE IF NOT EXISTS tb_user_profile"),
"DDL must contain CREATE TABLE"
);
assert!(content.contains("id UUID NOT NULL"), "id field must be UUID NOT NULL");
assert!(content.contains("email TEXT"), "email field must be TEXT");
assert!(
!content.contains("email TEXT NOT NULL"),
"nullable field must not have NOT NULL"
);
}
#[test]
fn test_emit_ddl_to_dir_empty_schema_no_files() {
let tmp = tempfile::tempdir().unwrap();
let schema = CompiledSchema::default();
let dir = tmp.path().to_str().unwrap();
emit_ddl_to_dir(&schema, dir).unwrap();
let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
assert!(entries.is_empty(), "no DDL files must be written for empty schema");
}
fn artifact_types() -> Vec<TypeDefinition> {
let mut user = TypeDefinition::new("User", "tv_user");
user.fields = vec![
FieldDefinition::new("id", FieldType::Id),
FieldDefinition::nullable("email", FieldType::String),
];
let mut post = TypeDefinition::new("Post", "tv_post");
post.fields = vec![
FieldDefinition::new("id", FieldType::Id),
FieldDefinition::new("title", FieldType::String),
];
let mut doc = TypeDefinition::new("Doc", "tv_doc");
doc.fields = vec![
FieldDefinition::new("id", FieldType::Id),
FieldDefinition::new("embedding", FieldType::Vector)
.with_vector_config(fraiseql_core::schema::VectorConfig::new(3)),
];
vec![user, post, doc]
}
fn emitted(types: Vec<TypeDefinition>) -> std::collections::BTreeMap<String, Vec<u8>> {
let tmp = tempfile::tempdir().unwrap();
let schema = CompiledSchema {
types,
..Default::default()
};
emit_ddl_to_dir(&schema, tmp.path().to_str().unwrap()).unwrap();
std::fs::read_dir(tmp.path())
.unwrap()
.map(|entry| {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
(name, std::fs::read(&path).unwrap())
})
.collect()
}
#[test]
fn the_artifact_is_byte_identical_whatever_the_type_order() {
let forward = emitted(artifact_types());
let mut reversed_types = artifact_types();
reversed_types.reverse();
assert_eq!(forward, emitted(reversed_types));
assert_eq!(forward, emitted(artifact_types()), "and from run to run");
assert_eq!(forward.keys().collect::<Vec<_>>(), ["doc.sql", "post.sql", "user.sql"]);
}
#[test]
fn every_file_names_its_format_and_its_compiler() {
for (name, bytes) in emitted(artifact_types()) {
let text = String::from_utf8(bytes).unwrap();
let mut lines = text.lines();
assert_eq!(
lines.next(),
Some(format!("-- fraiseql emit-ddl format {EMIT_DDL_FORMAT}").as_str()),
"{name}"
);
assert_eq!(
lines.next(),
Some(
format!("-- Generated by fraiseql-cli {}", env!("CARGO_PKG_VERSION")).as_str()
),
"{name}"
);
}
}
#[test]
fn two_types_with_one_table_name_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let schema = CompiledSchema {
types: vec![
TypeDefinition::new("HTTPServer", "tv_http_server"),
TypeDefinition::new("HttpServer", "tv_http_server_2"),
],
..Default::default()
};
let refusal = emit_ddl_to_dir(&schema, tmp.path().to_str().unwrap())
.expect_err("one file for two types is refused")
.to_string();
assert!(refusal.contains("HTTPServer") && refusal.contains("HttpServer"), "{refusal}");
assert!(refusal.contains("http_server"), "{refusal}");
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0, "nothing is written");
}
}
mod dependency_graph_tests {
use super::super::dependency_graph::*;
#[test]
fn test_graph_format_from_str() {
assert_eq!("json".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
assert_eq!("dot".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
assert_eq!("graphviz".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
assert_eq!("mermaid".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
assert_eq!("md".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
assert_eq!("d2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
assert_eq!("console".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
assert_eq!("text".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
}
#[test]
fn test_graph_format_case_insensitive() {
assert_eq!("JSON".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
assert_eq!("DOT".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
assert_eq!("MERMAID".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
assert_eq!("D2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
}
#[test]
fn test_graph_format_invalid() {
let result = "invalid".parse::<GraphFormat>();
let err = result.expect_err("expected Err for unknown graph format");
assert!(err.contains("Unknown format"), "expected 'Unknown format' in: {err}");
}
#[test]
fn test_graph_format_display() {
assert_eq!(GraphFormat::Json.to_string(), "json");
assert_eq!(GraphFormat::Dot.to_string(), "dot");
assert_eq!(GraphFormat::Mermaid.to_string(), "mermaid");
assert_eq!(GraphFormat::D2.to_string(), "d2");
assert_eq!(GraphFormat::Console.to_string(), "console");
}
fn make_two_node_output() -> DependencyGraphOutput {
DependencyGraphOutput {
type_count: 2,
nodes: vec![
GraphNode {
name: "Query".to_string(),
dependency_count: 1,
dependent_count: 0,
is_root: true,
},
GraphNode {
name: "User".to_string(),
dependency_count: 0,
dependent_count: 1,
is_root: false,
},
],
edges: vec![GraphEdge {
from: "Query".to_string(),
to: "User".to_string(),
}],
cycles: vec![],
unused_types: vec![],
stats: GraphStats {
total_types: 2,
total_edges: 1,
cycle_count: 0,
unused_count: 0,
avg_dependencies: 0.5,
max_depth: 1,
most_depended_on: vec!["User".to_string()],
},
}
}
#[test]
fn test_to_dot_contains_expected_elements() {
let dot = to_dot(&make_two_node_output());
assert!(dot.contains("digraph schema_dependencies"));
assert!(dot.contains("Query"));
assert!(dot.contains("User"));
assert!(dot.contains("\"Query\" -> \"User\""));
}
#[test]
fn test_to_mermaid_contains_expected_elements() {
let mermaid = to_mermaid(&make_two_node_output());
assert!(mermaid.contains("```mermaid"));
assert!(mermaid.contains("graph LR"));
assert!(mermaid.contains("Query"));
assert!(mermaid.contains("User"));
assert!(mermaid.contains("Query --> User"));
}
#[test]
fn test_to_d2_contains_expected_elements() {
let d2 = to_d2(&make_two_node_output());
assert!(d2.contains("# Schema Dependency Graph"));
assert!(d2.contains("direction: right"));
assert!(d2.contains("roots:"));
assert!(d2.contains("Query"));
assert!(d2.contains("User"));
assert!(d2.contains("roots.Query -> User"));
}
#[test]
fn test_to_d2_shows_unused() {
let output = DependencyGraphOutput {
type_count: 1,
nodes: vec![GraphNode {
name: "Orphan".to_string(),
dependency_count: 0,
dependent_count: 0,
is_root: false,
}],
edges: vec![],
cycles: vec![],
unused_types: vec!["Orphan".to_string()],
stats: GraphStats {
total_types: 1,
total_edges: 0,
cycle_count: 0,
unused_count: 1,
avg_dependencies: 0.0,
max_depth: 0,
most_depended_on: vec![],
},
};
let d2 = to_d2(&output);
assert!(d2.contains("unused:"));
assert!(d2.contains("Unused Types"));
assert!(d2.contains("Orphan"));
assert!(d2.contains("stroke-dash"));
}
#[test]
fn test_to_d2_shows_cycles() {
let output = DependencyGraphOutput {
type_count: 2,
nodes: vec![
GraphNode {
name: "A".to_string(),
dependency_count: 1,
dependent_count: 1,
is_root: false,
},
GraphNode {
name: "B".to_string(),
dependency_count: 1,
dependent_count: 1,
is_root: false,
},
],
edges: vec![
GraphEdge {
from: "A".to_string(),
to: "B".to_string(),
},
GraphEdge {
from: "B".to_string(),
to: "A".to_string(),
},
],
cycles: vec![CycleInfo {
types: vec!["A".to_string(), "B".to_string()],
path: "A -> B -> A".to_string(),
is_self_reference: false,
}],
unused_types: vec![],
stats: GraphStats {
total_types: 2,
total_edges: 2,
cycle_count: 1,
unused_count: 0,
avg_dependencies: 1.0,
max_depth: 0,
most_depended_on: vec![],
},
};
let d2 = to_d2(&output);
assert!(d2.contains("CYCLE"));
assert!(d2.contains("stroke: \"#d32f2f\""));
assert!(d2.contains("# WARNING: Circular dependencies detected!"));
}
#[test]
fn test_to_console_contains_expected_elements() {
let console = to_console(&make_two_node_output());
assert!(console.contains("Schema Dependency Graph Analysis"));
assert!(console.contains("Total types: 2"));
assert!(console.contains("[ROOT] Query"));
assert!(console.contains("User"));
}
#[test]
fn test_to_console_shows_cycles() {
let output = DependencyGraphOutput {
type_count: 2,
nodes: vec![
GraphNode {
name: "A".to_string(),
dependency_count: 1,
dependent_count: 1,
is_root: false,
},
GraphNode {
name: "B".to_string(),
dependency_count: 1,
dependent_count: 1,
is_root: false,
},
],
edges: vec![
GraphEdge {
from: "A".to_string(),
to: "B".to_string(),
},
GraphEdge {
from: "B".to_string(),
to: "A".to_string(),
},
],
cycles: vec![CycleInfo {
types: vec!["A".to_string(), "B".to_string()],
path: "A -> B -> A".to_string(),
is_self_reference: false,
}],
unused_types: vec![],
stats: GraphStats {
total_types: 2,
total_edges: 2,
cycle_count: 1,
unused_count: 0,
avg_dependencies: 1.0,
max_depth: 0,
most_depended_on: vec![],
},
};
let console = to_console(&output);
assert!(console.contains("CIRCULAR DEPENDENCIES"));
assert!(console.contains("A -> B -> A"));
}
#[test]
fn test_to_console_shows_unused() {
let output = DependencyGraphOutput {
type_count: 1,
nodes: vec![GraphNode {
name: "Orphan".to_string(),
dependency_count: 0,
dependent_count: 0,
is_root: false,
}],
edges: vec![],
cycles: vec![],
unused_types: vec!["Orphan".to_string()],
stats: GraphStats {
total_types: 1,
total_edges: 0,
cycle_count: 0,
unused_count: 1,
avg_dependencies: 0.0,
max_depth: 0,
most_depended_on: vec![],
},
};
let console = to_console(&output);
assert!(console.contains("UNUSED TYPES"));
assert!(console.contains("Orphan"));
assert!(console.contains("[UNUSED]"));
}
#[test]
fn test_cycle_info_from_cycle_path() {
use fraiseql_core::schema::CyclePath;
let cycle = CyclePath::new(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
let info = CycleInfo::from(&cycle);
assert_eq!(info.types, vec!["A", "B", "C"]);
assert_eq!(info.path, "A → B → C → A");
assert!(!info.is_self_reference);
}
#[test]
fn test_cycle_info_self_reference() {
use fraiseql_core::schema::CyclePath;
let cycle = CyclePath::new(vec!["Node".to_string()]);
let info = CycleInfo::from(&cycle);
assert!(info.is_self_reference);
assert_eq!(info.path, "Node → Node");
}
}
mod doctor_tests {
use std::io::Write;
use tempfile::NamedTempFile;
use super::super::doctor::*;
fn temp_file_with(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
use crate::schema::pg_catalog::ChangeLogRlsStatus;
fn rls_status(rls_enabled: bool, can_bypass: bool, is_owner: bool) -> ChangeLogRlsStatus {
ChangeLogRlsStatus {
rls_enabled,
can_bypass,
is_owner,
role_name: "app_role".to_string(),
}
}
#[test]
fn changelog_rls_absent_table_is_pass() {
assert_eq!(changelog_rls_check(None).status, CheckStatus::Pass);
}
#[test]
fn changelog_rls_disabled_is_informational_pass() {
let s = rls_status(false, false, false);
assert_eq!(changelog_rls_check(Some(&s)).status, CheckStatus::Pass);
}
#[test]
fn changelog_rls_enabled_with_bypass_is_pass() {
let s = rls_status(true, true, false);
let c = changelog_rls_check(Some(&s));
assert_eq!(c.status, CheckStatus::Pass);
assert!(c.detail.contains("BYPASSRLS"));
}
#[test]
fn changelog_rls_enabled_with_owner_is_pass() {
let s = rls_status(true, false, true);
assert_eq!(changelog_rls_check(Some(&s)).status, CheckStatus::Pass);
}
#[test]
fn changelog_rls_enabled_without_bypass_warns_the_footgun() {
let s = rls_status(true, false, false);
let c = changelog_rls_check(Some(&s));
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("ZERO rows"));
assert!(c.hint.is_some());
}
use crate::schema::pg_catalog::{CaptureFnSecurity, PublicGrant};
fn grant(relname: &str, privileges: &[&str]) -> PublicGrant {
PublicGrant {
relname: relname.to_string(),
privileges: privileges.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn changelog_public_grants_absent_is_pass() {
assert_eq!(changelog_public_grants_check(&[]).status, CheckStatus::Pass);
}
#[test]
fn changelog_public_grants_clean_is_pass() {
let g = [
grant("tb_entity_change_log", &[]),
grant("v_entity_change_log", &[]),
grant("v_entity_change_log_debezium", &[]),
];
let c = changelog_public_grants_check(&g);
assert_eq!(c.status, CheckStatus::Pass);
assert!(c.detail.contains("least-privilege"));
}
#[test]
fn changelog_public_grants_world_readable_warns() {
let g = [
grant("tb_entity_change_log", &[]),
grant("v_entity_change_log", &["SELECT"]),
];
let c = changelog_public_grants_check(&g);
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("v_entity_change_log"));
assert!(c.detail.contains("SELECT"));
assert!(c.hint.is_some());
}
use crate::schema::pg_catalog::ChangeLogActorStats;
fn actor_stats(
null_rows: i64,
unknown: &[&str],
constraint_installed: bool,
) -> ChangeLogActorStats {
ChangeLogActorStats {
null_rows,
unknown_values: unknown.iter().map(ToString::to_string).collect(),
constraint_installed,
}
}
#[test]
fn changelog_actor_absent_table_is_pass() {
let checks = changelog_actor_check(None);
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, CheckStatus::Pass);
}
#[test]
fn changelog_actor_clean_is_pass() {
let s = actor_stats(0, &[], true);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, CheckStatus::Pass);
}
#[test]
fn changelog_actor_unknown_values_fail() {
let s = actor_stats(0, &["superhuman"], true);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, CheckStatus::Fail);
assert!(checks[0].detail.contains("superhuman"));
assert!(checks[0].detail.contains("blocks new ones"));
}
#[test]
fn changelog_actor_unknown_values_without_constraint_fail_mentions_missing_guard() {
let s = actor_stats(0, &["superhuman"], false);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks[0].status, CheckStatus::Fail);
assert!(checks[0].detail.contains("NOT"), "must flag the missing constraint too");
}
#[test]
fn changelog_actor_missing_constraint_warns() {
let s = actor_stats(0, &[], false);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, CheckStatus::Warn);
assert!(checks[0].detail.contains("chk_entity_change_log_actor_type"));
}
#[test]
fn changelog_actor_null_rows_warn() {
let s = actor_stats(7, &[], true);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, CheckStatus::Warn);
assert!(checks[0].detail.contains('7'));
}
#[test]
fn changelog_actor_rogue_and_null_produce_both_checks() {
let s = actor_stats(3, &["rogue"], true);
let checks = changelog_actor_check(Some(&s));
assert_eq!(checks.len(), 2);
assert_eq!(checks[0].status, CheckStatus::Fail);
assert_eq!(checks[1].status, CheckStatus::Warn);
}
use crate::schema::pg_catalog::SecurityInvokerAudit;
#[test]
fn security_invoker_no_rls_is_pass() {
let audit = SecurityInvokerAudit {
rls_in_use: false,
views_without_invoker: vec!["v_bad".into()],
};
assert_eq!(security_invoker_check(&audit).status, CheckStatus::Pass);
}
#[test]
fn security_invoker_all_invoker_is_pass() {
let audit = SecurityInvokerAudit {
rls_in_use: true,
views_without_invoker: vec![],
};
let c = security_invoker_check(&audit);
assert_eq!(c.status, CheckStatus::Pass);
assert!(c.detail.contains("honoured"));
}
#[test]
fn security_invoker_missing_under_rls_warns() {
let audit = SecurityInvokerAudit {
rls_in_use: true,
views_without_invoker: vec!["v_post".into(), "tv_user".into()],
};
let c = security_invoker_check(&audit);
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("v_post") && c.detail.contains("tv_user"));
assert!(c.detail.contains("BYPASS"));
assert!(c.hint.is_some());
}
#[test]
fn capture_fn_absent_is_pass() {
assert_eq!(capture_fn_security_check(None).status, CheckStatus::Pass);
}
#[test]
fn capture_fn_definer_with_pinned_search_path_is_pass() {
let s = CaptureFnSecurity {
security_definer: true,
search_path: Some("pg_catalog, core".to_string()),
};
let c = capture_fn_security_check(Some(&s));
assert_eq!(c.status, CheckStatus::Pass);
assert!(c.detail.contains("pg_catalog, core"));
}
#[test]
fn capture_fn_invoker_warns() {
let s = CaptureFnSecurity {
security_definer: false,
search_path: None,
};
let c = capture_fn_security_check(Some(&s));
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("SECURITY INVOKER"));
assert!(c.hint.is_some());
}
#[test]
fn capture_fn_definer_without_search_path_warns_the_injection_vector() {
let s = CaptureFnSecurity {
security_definer: true,
search_path: None,
};
let c = capture_fn_security_check(Some(&s));
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("search_path"));
assert!(c.hint.is_some());
}
#[test]
fn test_schema_exists_pass() {
let f = temp_file_with("{}");
let result = check_schema_exists(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_schema_exists_fail() {
let result = check_schema_exists(std::path::Path::new("/nonexistent/schema.compiled.json"));
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn test_schema_parses_valid_json() {
let f = temp_file_with(r#"{"types":[],"queries":[],"mutations":[]}"#);
let result = check_schema_parses(f.path());
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.contains("types=0"));
}
#[test]
fn test_schema_parses_invalid_json() {
let f = temp_file_with("not json {{{");
let result = check_schema_parses(f.path());
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.hint.is_some());
}
fn compiled_artifact_json() -> String {
serde_json::to_string(&fraiseql_core::schema::CompiledSchema::default())
.expect("a compiled schema serializes")
}
#[test]
fn test_schema_version_this_build_passes() {
let f = temp_file_with(&compiled_artifact_json());
let result = check_schema_version(f.path());
assert_eq!(
result.status,
CheckStatus::Pass,
"a schema this build compiled must not be reported as a problem: {result:?}"
);
assert!(
result.detail.contains(fraiseql_core::schema::CURRENT_FRAISEQL_VERSION),
"the detail names the build: {}",
result.detail
);
}
#[test]
fn test_schema_version_unstamped_fails() {
let f = temp_file_with(r#"{"types":[]}"#);
let result = check_schema_version(f.path());
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.hint.is_some());
}
#[test]
fn test_schema_version_other_build_fails() {
let f = temp_file_with(r#"{"fraiseql_version":"2.14.0","types":[]}"#);
let result = check_schema_version(f.path());
assert_eq!(result.status, CheckStatus::Fail);
assert!(
result.detail.contains("2.14.0"),
"the detail names the producing build: {}",
result.detail
);
assert!(
result.detail.contains(fraiseql_core::schema::CURRENT_FRAISEQL_VERSION),
"and this one: {}",
result.detail
);
}
#[test]
fn test_toml_exists_pass() {
let f = temp_file_with(
"[schema]\nname = \"test\"\nversion = \"1.0\"\ndatabase_target = \"postgresql\"\n",
);
let result = check_toml_exists(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_toml_exists_warn() {
let result = check_toml_exists(std::path::Path::new("/nonexistent/fraiseql.toml"));
assert_eq!(result.status, CheckStatus::Warn);
}
#[test]
fn test_toml_parses_valid() {
let toml =
"[schema]\nname = \"myapp\"\nversion = \"1.0\"\ndatabase_target = \"postgresql\"\n";
let f = temp_file_with(toml);
let result = check_toml_parses(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_toml_parses_invalid_syntax() {
let f = temp_file_with("this is not [[[ valid toml");
let result = check_toml_parses(f.path());
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.hint.is_some());
}
#[test]
fn test_db_url_set_via_override() {
let result = check_database_url_set(Some("postgres://localhost/test"));
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_db_url_not_set() {
temp_env::with_var_unset("DATABASE_URL", || {
let result = check_database_url_set(None);
assert_eq!(result.status, CheckStatus::Fail);
});
}
#[test]
fn test_db_url_from_env() {
temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
let result = check_database_url_set(None);
assert_eq!(result.status, CheckStatus::Pass);
});
}
#[test]
fn test_db_reachable_unreachable_port() {
temp_env::with_var_unset("DATABASE_URL", || {
let result = check_db_reachable(Some("postgres://localhost:1/db"));
assert_eq!(result.status, CheckStatus::Fail);
let hint = result.hint.unwrap();
assert!(hint.contains("pg_isready"), "hint should mention pg_isready: {hint}");
});
}
#[test]
fn test_db_reachable_no_url() {
temp_env::with_var_unset("DATABASE_URL", || {
let result = check_db_reachable(None);
assert_eq!(result.status, CheckStatus::Fail);
});
}
#[test]
fn test_db_reachable_hostname_resolves_and_passes() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let url = format!("postgres://user:pass@localhost:{port}/db");
let result = check_db_reachable(Some(&url));
assert_eq!(
result.status,
CheckStatus::Pass,
"a live listener reached via hostname must pass, got: {}",
result.detail
);
}
#[test]
fn test_db_reachable_unresolvable_host_reports_resolution_failure() {
let result = check_db_reachable(Some("postgres://u:p@db.invalid:5432/db"));
assert_eq!(result.status, CheckStatus::Fail);
assert!(
result.detail.contains("resolve"),
"an unresolvable host must be reported as a resolution failure, got: {}",
result.detail
);
assert!(
!result.detail.contains("0.0.0.0"),
"the probe must never dial a sentinel address, got: {}",
result.detail
);
}
#[test]
fn test_redis_reachable_hostname_resolves_and_passes() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
temp_env::with_var("REDIS_URL", Some(format!("redis://localhost:{port}")), || {
let result = check_redis_reachable();
assert_eq!(
result.status,
CheckStatus::Pass,
"a live Redis listener reached via hostname must pass, got: {}",
result.detail
);
});
}
#[test]
fn test_jwt_secret_set() {
temp_env::with_var("FRAISEQL_JWT_SECRET", Some("supersecret"), || {
let result = check_jwt_secret();
assert_eq!(result.status, CheckStatus::Pass);
});
}
#[test]
fn test_jwt_secret_missing() {
temp_env::with_var_unset("FRAISEQL_JWT_SECRET", || {
let result = check_jwt_secret();
assert_eq!(result.status, CheckStatus::Warn);
assert!(result.hint.is_some());
});
}
#[test]
fn test_redis_not_set_is_pass() {
temp_env::with_var_unset("REDIS_URL", || {
let result = check_redis_reachable();
assert_eq!(result.status, CheckStatus::Pass);
});
}
#[test]
fn test_redis_set_but_unreachable() {
temp_env::with_var("REDIS_URL", Some("redis://localhost:1"), || {
let result = check_redis_reachable();
assert_eq!(result.status, CheckStatus::Fail);
});
}
#[test]
fn test_tls_unreadable_config_is_warn_not_pass() {
let result = check_tls(std::path::Path::new("/nonexistent/fraiseql.toml"));
assert_eq!(
result.status,
CheckStatus::Warn,
"a check that could not run must never render as a pass"
);
}
#[test]
fn test_tls_disabled_in_config_is_pass() {
let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n";
let f = temp_file_with(toml);
let result = check_tls(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_cache_auth_coherence_cache_disabled_is_pass() {
let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n";
let f = temp_file_with(toml);
let result = check_rls_cache_coherence(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_cache_auth_coherence_cache_enabled_no_policy_is_warn() {
let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n\n[caching]\nenabled = true\n\n[security]\nmulti_tenant = false\n";
let f = temp_file_with(toml);
let result = check_rls_cache_coherence(f.path());
assert!(matches!(result.status, CheckStatus::Pass | CheckStatus::Warn));
}
#[test]
fn test_cache_auth_coherence_cache_enabled_with_policy_is_pass() {
let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n\n[caching]\nenabled = true\n\n[security.rls]\nenabled = true\n";
let f = temp_file_with(toml);
let result = check_rls_cache_coherence(f.path());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_parse_host_port_postgres() {
let (host, port) =
parse_host_port("postgres://user:pass@db.example.com:5432/mydb").unwrap();
assert_eq!(host, "db.example.com");
assert_eq!(port, 5432);
}
#[test]
fn test_parse_host_port_localhost() {
let (host, port) = parse_host_port("postgres://localhost:5432/db").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn test_parse_host_port_ipv6() {
let result = parse_host_port("postgres://[::1]:5432/db");
assert!(result.is_some());
let (host, port) = result.unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 5432);
}
#[test]
fn test_parse_host_port_invalid() {
assert!(parse_host_port("not-a-url").is_none());
}
#[test]
fn test_json_serialization() {
let checks = vec![
DoctorCheck::pass("Test pass", "detail"),
DoctorCheck::warn("Test warn", "detail", "hint text"),
DoctorCheck::fail("Test fail", "detail", "hint text"),
];
let json = serde_json::to_string(&checks).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed[0]["status"], "pass");
assert_eq!(parsed[1]["status"], "warn");
assert_eq!(parsed[2]["status"], "fail");
}
use fraiseql_observers::migrations::ENTITY_CHANGE_LOG_CONTRACT;
use crate::schema::pg_catalog::LiveColumn;
fn clean_live() -> Vec<LiveColumn> {
ENTITY_CHANGE_LOG_CONTRACT
.iter()
.map(|c| LiveColumn {
name: c.name.to_string(),
udt_name: c.udt.to_string(),
})
.collect()
}
#[test]
fn changelog_contract_clean_passes() {
let checks = changelog_contract_drift(&clean_live());
assert_eq!(checks.len(), 1, "a clean contract yields exactly one check");
assert_eq!(checks[0].status, CheckStatus::Pass);
assert!(checks[0].detail.contains("contract columns present"));
}
#[test]
fn changelog_contract_absent_table_warns() {
let checks = changelog_contract_drift(&[]);
assert_eq!(checks.len(), 1, "an absent table yields a single warning");
assert_eq!(checks[0].status, CheckStatus::Warn);
assert!(checks[0].detail.contains("not found"));
let hint = checks[0].hint.as_deref().unwrap();
assert!(hint.contains("fraiseql setup"), "names the setup install path: {hint}");
assert!(hint.contains("migrate up"), "keeps the migrate path: {hint}");
}
#[test]
fn changelog_contract_missing_tenant_id_warns() {
let live: Vec<LiveColumn> =
clean_live().into_iter().filter(|c| c.name != "tenant_id").collect();
let checks = changelog_contract_drift(&live);
assert!(checks.iter().all(|c| c.status != CheckStatus::Fail), "missing != fatal");
let warn = checks
.iter()
.find(|c| c.status == CheckStatus::Warn)
.expect("missing column produces a warning");
assert!(warn.detail.contains("tenant_id"));
assert!(warn.detail.contains("missing"));
}
#[test]
fn changelog_contract_object_id_text_fails() {
let mut live = clean_live();
for col in &mut live {
if col.name == "object_id" {
col.udt_name = "text".to_string();
}
}
let checks = changelog_contract_drift(&live);
let fail = checks
.iter()
.find(|c| c.status == CheckStatus::Fail)
.expect("a type mismatch is fatal");
assert!(fail.detail.contains("object_id"));
assert!(fail.detail.contains("text"));
assert!(fail.detail.contains("uuid"));
assert!(fail.hint.as_deref().unwrap().contains("cannot retype"));
}
#[test]
fn changelog_contract_extra_column_warns() {
let mut live = clean_live();
live.push(LiveColumn {
name: "app_custom_col".to_string(),
udt_name: "text".to_string(),
});
let checks = changelog_contract_drift(&live);
assert!(checks.iter().all(|c| c.status != CheckStatus::Fail), "extra column != fatal");
let warn = checks
.iter()
.find(|c| c.detail.contains("app_custom_col"))
.expect("extra column is reported");
assert_eq!(warn.status, CheckStatus::Warn);
assert!(warn.detail.contains("non-contract"));
}
use crate::schema::mutation_contract::{ContractReport, ContractViolation, MutationReport};
#[test]
fn mutation_contract_clean_report_is_single_pass() {
let report = ContractReport {
checked: 3,
skipped: 1,
mutations: vec![],
};
let checks = mutation_contract_drift(&report);
assert_eq!(checks.len(), 1, "a clean report yields exactly one check");
assert_eq!(checks[0].status, CheckStatus::Pass);
assert!(checks[0].detail.contains("DB-backed mutation"));
}
#[test]
fn mutation_contract_missing_function_is_fatal() {
let report = ContractReport {
checked: 1,
skipped: 0,
mutations: vec![MutationReport {
mutation: "createWidget".to_string(),
sql_source: "fn_create_widget".to_string(),
violations: vec![ContractViolation::MissingFunction],
}],
};
let checks = mutation_contract_drift(&report);
let fail = checks
.iter()
.find(|c| c.status == CheckStatus::Fail)
.expect("a missing backing function is fatal");
assert!(fail.detail.contains("createWidget"));
assert!(fail.detail.contains("fn_create_widget"));
assert!(fail.hint.is_some());
}
fn temp_file_ext(content: &str, ext: &str) -> NamedTempFile {
let mut f = tempfile::Builder::new()
.suffix(&format!(".{ext}"))
.tempfile()
.expect("temp file");
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn effective_db_url_prefers_db_url_then_against_db() {
assert_eq!(effective_db_url(Some("a"), Some("b")), Some("a"), "--db-url wins");
assert_eq!(
effective_db_url(None, Some("b")),
Some("b"),
"--against-db used absent --db-url"
);
assert_eq!(effective_db_url(Some("a"), None), Some("a"));
assert_eq!(effective_db_url(None, None), None, "neither → env fallback in the checks");
}
#[test]
fn against_db_only_run_reports_database_url_set() {
let url = "postgres://u:p@localhost:5432/db";
let check = check_database_url_set(effective_db_url(None, Some(url)));
assert_eq!(check.status, CheckStatus::Pass, "got {check:?}");
}
#[test]
fn runtime_config_toml_with_compiled_json_schema_is_syntax_only() {
let config = temp_file_ext("[server]\nbind = \"0.0.0.0:8000\"\n", "toml");
let schema = temp_file_ext("{}", "json");
let check = check_config_toml(config.path(), schema.path());
assert_eq!(check.status, CheckStatus::Pass, "runtime config syntax should pass: {check:?}");
}
#[test]
fn malformed_config_toml_still_fails() {
let config = temp_file_ext("this is = = not valid toml\n", "toml");
let schema = temp_file_ext("{}", "json");
let check = check_config_toml(config.path(), schema.path());
assert_eq!(check.status, CheckStatus::Fail, "malformed TOML must fail: {check:?}");
}
#[test]
fn schema_toml_as_schema_is_still_schema_parsed() {
let config = temp_file_ext("[server]\nbind = \"0.0.0.0:8000\"\n", "toml");
let schema_toml = temp_file_ext("[[types]]\nname = \"User\"\n", "toml");
let check = check_config_toml(config.path(), schema_toml.path());
assert_eq!(
check.status,
CheckStatus::Fail,
"schema parse should run for a .toml schema: {check:?}"
);
}
}
mod explain_tests {
use super::super::explain::*;
#[test]
fn test_explain_simple_query() {
let query = "query { users { id } }";
let result = run(query);
let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok for simple query: {e}"));
assert_eq!(cmd_result.status, "success");
}
#[test]
fn test_explain_invalid_query_fails() {
let query = "query { invalid {";
let result = run(query);
assert!(result.is_err(), "expected Err for invalid query, got: {result:?}");
}
#[test]
fn test_explain_detects_deep_nesting() {
let query = "query { a { b { c { d { e { f { g { h { i { j { k { l } } } } } } } } } } } }";
let result = run(query);
let cmd_result =
result.unwrap_or_else(|e| panic!("expected Ok for deep nesting query: {e}"));
if let Some(warnings) = cmd_result.data {
assert!(!warnings.to_string().is_empty());
}
}
}
mod generate_views_tests {
use super::super::generate_views::*;
#[test]
fn test_refresh_strategy_from_str() {
assert_eq!(RefreshStrategy::parse("trigger-based").unwrap(), RefreshStrategy::TriggerBased);
assert_eq!(RefreshStrategy::parse("trigger").unwrap(), RefreshStrategy::TriggerBased);
assert_eq!(RefreshStrategy::parse("scheduled").unwrap(), RefreshStrategy::Scheduled);
assert!(
RefreshStrategy::parse("invalid").is_err(),
"expected Err for unknown refresh strategy"
);
}
#[test]
fn test_refresh_strategy_display() {
assert_eq!(RefreshStrategy::TriggerBased.to_string(), "trigger-based");
assert_eq!(RefreshStrategy::Scheduled.to_string(), "scheduled");
}
#[test]
fn test_validate_view_name_vector_arrow() {
assert_eq!(validate_view_name("va_user_embeddings").unwrap(), "Vector Arrow (va_)");
}
#[test]
fn test_validate_view_name_table_vector() {
assert_eq!(validate_view_name("tv_user_profile").unwrap(), "Table Vector (tv_)");
}
#[test]
fn test_validate_view_name_table_arrow() {
assert_eq!(validate_view_name("ta_orders").unwrap(), "Table Arrow (ta_)");
}
#[test]
fn test_validate_view_name_invalid() {
assert!(
validate_view_name("invalid_view").is_err(),
"expected Err for invalid_view prefix"
);
assert!(
validate_view_name("v_user").is_err(),
"expected Err for v_ prefix (not va_/tv_/ta_)"
);
}
#[test]
fn test_generate_view_sql_vector_arrow() {
let sql = generate_view_sql(
"User",
"v_user",
"va_user_embeddings",
"Vector Arrow (va_)",
RefreshStrategy::TriggerBased,
false,
false,
);
assert!(sql.contains("CREATE VIEW va_user_embeddings"));
assert!(sql.contains("Entity: User"));
assert!(sql.contains("Vector Arrow (va_)"));
assert!(sql.contains("trigger-based"));
assert!(
sql.contains("FROM v_user"),
"must use entity sql_source, not schema_placeholder"
);
assert!(!sql.contains("schema_placeholder"));
}
#[test]
fn test_generate_view_sql_table_vector() {
let sql = generate_view_sql(
"Order",
"v_order",
"tv_order_summary",
"Table Vector (tv_)",
RefreshStrategy::Scheduled,
false,
false,
);
assert!(sql.contains("CREATE MATERIALIZED VIEW tv_order_summary"));
assert!(sql.contains("Entity: Order"));
assert!(sql.contains("scheduled"));
assert!(
sql.contains("FROM v_order"),
"must use entity sql_source, not schema_placeholder"
);
assert!(!sql.contains("schema_placeholder"));
}
#[test]
fn test_generate_view_sql_with_composition_views() {
let sql = generate_view_sql(
"User",
"v_user",
"tv_user_profile",
"Table Vector (tv_)",
RefreshStrategy::TriggerBased,
true,
false,
);
assert!(sql.contains("Composition views"));
assert!(sql.contains("_recent"));
assert!(sql.contains("_count"));
assert!(!sql.contains("{}"), "no unfilled placeholder may reach the DDL:\n{sql}");
assert!(
sql.contains("FROM tv_user_profile"),
"composition views must read the generated view:\n{sql}"
);
}
#[test]
fn test_generate_view_sql_tv_is_rerunnable_and_self_consistent() {
let sql = generate_view_sql(
"User",
"v_user",
"tv_user_profile",
"Table Vector (tv_)",
RefreshStrategy::TriggerBased,
true,
false,
);
assert!(
sql.contains("DROP MATERIALIZED VIEW IF EXISTS tv_user_profile"),
"tv_ drop must match the materialized object class:\n{sql}"
);
assert!(
!sql.contains("DROP VIEW IF EXISTS tv_user_profile"),
"a plain DROP VIEW on a materialized view fails on re-run:\n{sql}"
);
assert!(
sql.contains("materialized_at > NOW()"),
"_recent on a tv_ view must filter on materialized_at:\n{sql}"
);
}
#[test]
fn test_generate_view_sql_monitoring_is_stable_not_immutable() {
let sql = generate_view_sql(
"User",
"v_user",
"ta_users",
"Table Arrow (ta_)",
RefreshStrategy::TriggerBased,
false,
true,
);
assert!(sql.contains("STABLE"), "monitoring function must be STABLE:\n{sql}");
assert!(!sql.contains("IMMUTABLE"), "IMMUTABLE mislabels a relation-reading fn:\n{sql}");
}
#[test]
fn test_generate_view_sql_with_monitoring() {
let sql = generate_view_sql(
"User",
"v_user",
"tv_user_profile",
"Table Vector (tv_)",
RefreshStrategy::TriggerBased,
false,
true,
);
assert!(sql.contains("Monitoring functions"));
assert!(sql.contains("monitor_tv_user_profile"));
assert!(sql.contains("metric_name"));
}
#[test]
fn test_generate_view_sql_full_options() {
let sql = generate_view_sql(
"User",
"v_user",
"ta_users",
"Table Arrow (ta_)",
RefreshStrategy::TriggerBased,
true,
true,
);
assert!(sql.contains("Entity: User"));
assert!(sql.contains("View: ta_users"));
assert!(sql.contains("Composition views"));
assert!(sql.contains("Monitoring functions"));
assert!(!sql.contains("schema_placeholder"));
}
#[test]
fn test_generate_view_sql_uses_real_sql_source() {
let sql = generate_view_sql(
"Product",
"v_product_catalog",
"ta_products",
"Table Arrow (ta_)",
RefreshStrategy::TriggerBased,
false,
false,
);
assert!(
sql.contains("FROM v_product_catalog"),
"generated SQL must use the entity's sql_source"
);
assert!(!sql.contains("schema_placeholder"), "placeholder must not appear in output");
}
}
mod introspect_facts_tests {
use super::super::introspect_facts::*;
#[test]
fn test_output_format_from_str() {
assert!(matches!(OutputFormat::parse("python"), Ok(OutputFormat::Python)));
assert!(matches!(OutputFormat::parse("json"), Ok(OutputFormat::Json)));
assert!(
OutputFormat::parse("invalid").is_err(),
"expected Err for unknown output format 'invalid'"
);
}
#[test]
fn test_format_as_python() {
use fraiseql_core::compiler::fact_table::{
DimensionColumn, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
};
let metadata = FactTableMetadata {
table_name: "tf_sales".to_string(),
type_name: None,
measures: vec![
MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
MeasureColumn {
name: "quantity".to_string(),
sql_type: SqlType::Int,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![FilterColumn {
name: "customer_id".to_string(),
sql_type: SqlType::Uuid,
indexed: true,
hierarchy: None,
}],
calendar_dimensions: vec![],
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
};
let output = format_as_python(&metadata);
assert!(output.contains("@fraiseql.fact_table"));
assert!(output.contains("'revenue'"));
assert!(output.contains("'quantity'"));
assert!(output.contains("'customer_id'"));
assert!(output.contains("class Sales:"));
}
}
mod lint_tests {
use std::io::Write;
use tempfile::NamedTempFile;
use super::super::lint::*;
fn default_opts() -> LintOptions {
LintOptions {
fail_on_critical: false,
fail_on_warning: false,
filter: LintCategoryFilter::default(),
}
}
#[test]
fn test_lint_valid_schema() {
let schema_json = r#"{
"types": [
{
"name": "Query",
"fields": [
{"name": "users", "type": "[User!]"}
]
},
{
"name": "User",
"fields": [
{"name": "id", "type": "ID", "isPrimaryKey": true},
{"name": "name", "type": "String"}
]
}
]
}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(schema_json.as_bytes()).unwrap();
let path = file.path().to_str().unwrap();
let result = run(path, default_opts());
let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok from lint run: {e:?}"));
assert_eq!(cmd_result.status, "success");
assert_eq!(cmd_result.command, "lint");
assert!(cmd_result.data.is_some());
}
#[test]
fn test_lint_file_not_found() {
let result = run("nonexistent_schema.json", default_opts());
assert!(result.is_err(), "file-not-found must return Err");
}
#[test]
fn test_lint_returns_score() {
let schema_json = r#"{"types": []}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(schema_json.as_bytes()).unwrap();
let path = file.path().to_str().unwrap();
let result = run(path, default_opts());
let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok from lint run: {e:?}"));
if let Some(data) = &cmd_result.data {
assert!(data.get("overall_score").is_some());
assert!(data.get("severity_counts").is_some());
assert!(data.get("categories").is_some());
}
}
}
mod migrate_tests {
use super::super::migrate::*;
static GLOBAL_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
use crate::output::OutputFormatter;
const ALL_VERBS: [ConfitureVerb; 6] = [
ConfitureVerb::Up,
ConfitureVerb::Down,
ConfitureVerb::Status,
ConfitureVerb::Generate,
ConfitureVerb::Validate,
ConfitureVerb::Preflight,
];
fn text() -> OutputFormatter {
OutputFormatter::new(false, false)
}
#[test]
fn every_verb_is_a_migrate_subcommand_with_migrations_dir() {
for verb in ALL_VERBS {
let argv = ConfitureCommand::new(verb, "db/migrations", &text()).argv();
assert_eq!(&argv[..2], ["migrate", verb.as_str()], "{verb:?}: {argv:?}");
assert!(
argv.windows(2).any(|w| w == ["--migrations-dir", "db/migrations"]),
"{verb:?} must pass --migrations-dir: {argv:?}"
);
assert!(!argv.iter().any(|a| a == "--source"), "{verb:?} passes --source: {argv:?}");
}
}
#[test]
fn generate_takes_the_name_positionally_before_the_options() {
let argv = ConfitureCommand::new(ConfitureVerb::Generate, "d", &text())
.name("add_posts")
.argv();
assert_eq!(argv, ["migrate", "generate", "add_posts", "--migrations-dir", "d"]);
}
#[test]
fn down_carries_steps() {
let argv = ConfitureCommand::new(ConfitureVerb::Down, "d", &text()).steps(3).argv();
assert_eq!(argv, ["migrate", "down", "--migrations-dir", "d", "--steps", "3"]);
}
#[test]
fn json_mode_adds_format_json_and_text_mode_does_not() {
let json = OutputFormatter::new(true, false);
for verb in ALL_VERBS {
let with = ConfitureCommand::new(verb, "d", &json).argv();
assert!(
with.ends_with(&["--format".to_string(), "json".to_string()]),
"{verb:?}: {with:?}"
);
let without = ConfitureCommand::new(verb, "d", &text()).argv();
assert!(!without.iter().any(|a| a == "--format"), "{verb:?}: {without:?}");
}
}
#[test]
fn database_url_is_kept_off_argv_and_out_of_debug() {
let command = ConfitureCommand::new(ConfitureVerb::Up, "d", &text())
.database_url("postgres://user:secret@db/app");
assert!(!command.argv().iter().any(|a| a.contains("secret")), "{:?}", command.argv());
let debug = format!("{command:?}");
assert!(!debug.contains("secret"), "Debug must redact the DSN: {debug}");
assert!(debug.contains("<redacted>"), "Debug must say a DSN is present: {debug}");
}
fn exported(command: &ConfitureCommand) -> Vec<(String, Option<String>)> {
command
.command()
.get_envs()
.map(|(name, value)| {
(
name.to_string_lossy().into_owned(),
value.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect()
}
#[test]
fn a_dsn_is_exported_as_confiture_database_url_and_argv_carries_no_config() {
for verb in [
ConfitureVerb::Up,
ConfitureVerb::Down,
ConfitureVerb::Status,
] {
let command =
ConfitureCommand::new(verb, "d", &text()).database_url("postgres://db/app");
assert_eq!(
exported(&command),
[("CONFITURE_DATABASE_URL".to_string(), Some("postgres://db/app".to_string()))],
"{verb:?} must export the DSN as CONFITURE_DATABASE_URL and nothing else"
);
assert!(
command.argv().iter().any(|a| a == "--no-config"),
"{verb:?} must pass --no-config with the DSN: {:?}",
command.argv()
);
}
}
#[test]
fn without_a_dsn_nothing_is_exported_and_no_config_is_not_passed() {
for verb in ALL_VERBS {
let command = ConfitureCommand::new(verb, "d", &text());
assert!(exported(&command).is_empty(), "{verb:?}: {:?}", exported(&command));
assert!(
!command.argv().iter().any(|a| a == "--no-config"),
"{verb:?} must not pass --no-config without a DSN: {:?}",
command.argv()
);
}
}
#[test]
fn no_config_is_an_option_of_up_down_status_and_preflight_only() {
let accepting: Vec<ConfitureVerb> =
ALL_VERBS.into_iter().filter(|verb| verb.accepts_no_config()).collect();
assert_eq!(
accepting,
[
ConfitureVerb::Up,
ConfitureVerb::Down,
ConfitureVerb::Status,
ConfitureVerb::Preflight
],
"measured against confiture 1.37.0"
);
}
#[test]
fn test_resolve_migration_dir_explicit() {
assert_eq!(resolve_migration_dir(Some("custom/dir")), "custom/dir");
}
#[test]
fn test_resolve_migration_dir_default() {
let dir = resolve_migration_dir(None);
assert!(!dir.is_empty());
}
#[test]
fn test_resolve_database_url_explicit() {
let url = resolve_database_url(Some("postgres://localhost/test")).unwrap();
assert_eq!(url, "postgres://localhost/test");
}
#[test]
fn test_resolve_database_url_no_source() {
let _guard = GLOBAL_STATE_LOCK
.lock()
.expect("GLOBAL_STATE_LOCK poisoned; a previous test panicked mid-migration");
let tmp = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let result = resolve_database_url(None);
assert!(result.is_err(), "expected Err when no database URL is available");
});
std::env::set_current_dir(original).unwrap();
}
#[test]
fn test_resolve_database_url_from_env() {
let _guard = GLOBAL_STATE_LOCK
.lock()
.expect("GLOBAL_STATE_LOCK poisoned; a previous test panicked mid-migration");
let tmp = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
temp_env::with_vars([("DATABASE_URL", Some("postgres://env/test"))], || {
let url = resolve_database_url(None).unwrap();
assert_eq!(url, "postgres://env/test");
});
std::env::set_current_dir(original).unwrap();
}
#[test]
fn resolve_database_url_prefers_the_flag_then_fraiseql_toml_then_the_environment() {
let _guard = GLOBAL_STATE_LOCK
.lock()
.expect("GLOBAL_STATE_LOCK poisoned; a previous test panicked mid-migration");
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("fraiseql.toml"),
"[database]\nurl = \"postgres://toml/app\"\n",
)
.unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
temp_env::with_vars([("DATABASE_URL", Some("postgres://env/app"))], || {
assert_eq!(
resolve_database_url(None).unwrap(),
"postgres://toml/app",
"fraiseql.toml must beat the DATABASE_URL environment variable"
);
assert_eq!(
resolve_database_url(Some("postgres://flag/app")).unwrap(),
"postgres://flag/app",
"the --database flag must beat both"
);
});
std::env::set_current_dir(original).unwrap();
}
const DATABASE_URL_SOURCES: [&str; 3] = ["--database", "fraiseql.toml", "DATABASE_URL"];
fn assert_names_in_resolver_order(what: &str, text: &str, tokens: &[&str]) {
let positions: Vec<usize> = tokens
.iter()
.map(|token| {
text.find(token)
.unwrap_or_else(|| panic!("{what} must name {token:?}; it says: {text}"))
})
.collect();
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"{what} must name the DSN sources in the order the resolver walks them, \
{tokens:?}; it says: {text}"
);
}
#[test]
fn help_states_the_database_url_order_the_resolver_implements() {
use clap::CommandFactory;
let cli = crate::cli::Cli::command();
let migrate = cli.find_subcommand("migrate").expect("`migrate` is a subcommand");
assert_eq!(
migrate.get_about().map(ToString::to_string).as_deref(),
Some("Run database migrations"),
"the one-line about shown in `fraiseql --help` must survive the explicit long_about"
);
let about = migrate.get_long_about().expect("`migrate` has a long about").to_string();
assert_eq!(
about, MIGRATE_LONG_ABOUT,
"migrate --help must be the text kept beside the resolver"
);
assert!(
about.contains("in this order"),
"migrate --help must state that the DSN sources are a precedence, not \
alternatives; it says: {about}"
);
assert_names_in_resolver_order("migrate --help", &about, &DATABASE_URL_SOURCES);
let setup = cli.find_subcommand("setup").expect("`setup` is a subcommand");
let database = setup
.get_arguments()
.find(|arg| arg.get_id().as_str() == "database")
.expect("`setup` has a --database flag");
let flag_help = database.get_help().expect("setup --database has help text").to_string();
assert_names_in_resolver_order(
"setup --database help",
&flag_help,
&DATABASE_URL_SOURCES[1..],
);
let examples = setup.get_after_help().expect("`setup` has EXAMPLES").to_string();
assert_names_in_resolver_order("setup EXAMPLES", &examples, &DATABASE_URL_SOURCES[1..]);
}
}
#[cfg(feature = "run-server")]
mod run_tests {
use std::net::SocketAddr;
use tempfile::TempDir;
use super::super::run::{
auto_detect_input, build_config_from, compile_schema, resolve_input, resolve_runtime_config,
};
use crate::config::runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
#[tokio::test]
async fn runs_compile_step_refuses_functions_its_build_cannot_run() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let schema = dir.path().join("schema.json");
std::fs::write(
&schema,
serde_json::json!({
"types": [{"name": "Order", "sql_source": "v_order", "is_input": false,
"fields": [{"name": "id", "type": "ID", "nullable": false}]}],
"queries": [{"name": "orders", "return_type": "Order", "returns_list": true,
"sql_source": "v_order"}],
"mutations": [{"name": "updateOrder", "return_type": "Order",
"sql_source": "fn_update_order", "operation": "update",
"invalidates_views": ["v_order"], "arguments": []}],
"functions": [{"name": "notify", "trigger": "after:mutation:Order:update",
"runtime": "Deno"}]
})
.to_string(),
)
.unwrap();
let functions_compiled_in = fraiseql_server::capability::REGISTRY
.iter()
.any(|r| r.meta.name == "functions" && r.compiled_in);
let result = compile_schema(&schema).await;
if functions_compiled_in {
let compiled = result.expect("a build with a function runtime serves functions");
let names: Vec<_> = compiled
.functions
.iter()
.flat_map(|f| &f.definitions)
.map(|d| d.name.as_str())
.collect();
assert_eq!(names, ["notify"], "the functions reach the server, not dropped");
} else {
let message = format!("{:#}", result.unwrap_err());
assert!(
message.contains("notify") && message.contains("`functions-runtime` feature"),
"the refusal names the function and the feature: {message}"
);
}
}
#[test]
fn capabilities_json_is_a_command() {
use clap::Parser as _;
let parsed = crate::cli::Cli::try_parse_from(["fraiseql", "capabilities", "--json"]);
assert!(parsed.is_ok(), "{}", parsed.err().map(|e| e.to_string()).unwrap_or_default());
}
#[test]
fn capabilities_report_lists_every_registration() {
let report = super::super::capabilities::report();
let entries = report["capabilities"].as_array().expect("a `capabilities` array");
let registry = fraiseql_server::capability::REGISTRY;
assert_eq!(entries.len(), registry.len());
for (entry, r) in entries.iter().zip(registry) {
assert_eq!(entry["name"], r.meta.name);
assert_eq!(entry["feature"], r.meta.feature);
assert_eq!(entry["compiled_in"], r.compiled_in);
assert_eq!(entry["docs"], serde_json::json!(r.meta.docs));
for field in ["maturity", "release"] {
assert!(entry[field].is_string(), "`{field}` of {}: {entry}", r.meta.name);
}
for field in ["runtime_keys", "compiled_sections"] {
assert!(entry[field].is_array(), "`{field}` of {}: {entry}", r.meta.name);
}
}
}
#[tokio::test]
async fn runs_compile_step_refuses_every_section_this_build_cannot_serve() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let schema = dir.path().join("schema.json");
std::fs::write(
&schema,
serde_json::json!({
"types": [{"name": "Order", "sql_source": "v_order", "is_input": false,
"fields": [{"name": "id", "type": "ID", "nullable": false}]}],
"queries": [{"name": "orders", "return_type": "Order", "returns_list": true,
"sql_source": "v_order"}],
"mcp_config": {"enabled": true},
"rest_config": {"enabled": true}
})
.to_string(),
)
.unwrap();
let compiled_in = |name: &str| {
fraiseql_server::capability::REGISTRY
.iter()
.find(|r| r.meta.name == name)
.unwrap_or_else(|| panic!("`{name}` is registered"))
.compiled_in
};
let result = compile_schema(&schema).await;
let unservable: Vec<_> = [("mcp", "mcp_config"), ("rest", "rest_config")]
.into_iter()
.filter(|(name, _)| !compiled_in(name))
.collect();
if unservable.is_empty() {
result.expect("this build serves both sections");
} else {
let message = format!("{:#}", result.unwrap_err());
for (feature, section) in unservable {
assert!(
message.contains(&format!("`{section}`"))
&& message.contains(&format!("`{feature}` feature")),
"the refusal must name `{section}` and its feature: {message}"
);
}
}
}
#[test]
fn test_resolve_input_explicit_existing_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("fraiseql.toml");
std::fs::write(&file, "").unwrap();
let result = resolve_input(Some(file.to_str().unwrap()));
assert!(result.is_ok(), "expected Ok, got {result:?}");
assert_eq!(result.unwrap(), file);
}
#[test]
fn test_resolve_input_explicit_missing_returns_helpful_error() {
let result = resolve_input(Some("/nonexistent/path/schema.json"));
let msg = result.expect_err("expected Err for missing path").to_string();
assert!(msg.contains("not found"), "expected 'not found' in: {msg}");
assert!(msg.contains("/nonexistent/path/schema.json"), "expected path in: {msg}");
}
#[test]
fn test_auto_detect_prefers_toml_over_json() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("fraiseql.toml"), "").unwrap();
std::fs::write(dir.path().join("schema.json"), "{}").unwrap();
let result = auto_detect_input(dir.path()).unwrap();
assert_eq!(result, dir.path().join("fraiseql.toml"));
}
#[test]
fn test_auto_detect_falls_back_to_schema_json() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("schema.json"), "{}").unwrap();
let result = auto_detect_input(dir.path()).unwrap();
assert_eq!(result, dir.path().join("schema.json"));
}
#[test]
fn test_auto_detect_no_files_returns_helpful_error() {
let dir = TempDir::new().unwrap();
let result = auto_detect_input(dir.path());
let msg = result.expect_err("expected Err when no files present").to_string();
assert!(msg.contains("No input file found"), "expected hint in: {msg}");
assert!(msg.contains("fraiseql run <INPUT>"), "expected usage in: {msg}");
}
#[test]
fn test_build_config_sets_db_url() {
temp_env::with_var_unset("DATABASE_URL", || {
let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let config = build_config_from(
"postgres://localhost/test",
addr,
&ServerRuntimeConfig::default(),
&DatabaseRuntimeConfig::default(),
false,
)
.expect("build_config_from");
assert_eq!(config.database_url, "postgres://localhost/test");
});
}
#[test]
fn test_build_config_sets_bind_addr() {
let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
let config = build_config_from(
"postgres://localhost/test",
addr,
&ServerRuntimeConfig::default(),
&DatabaseRuntimeConfig::default(),
false,
)
.expect("build_config_from");
assert_eq!(config.bind_addr, addr);
}
#[test]
fn test_build_config_introspection_enabled() {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let config = build_config_from(
"postgres://localhost/test",
addr,
&ServerRuntimeConfig::default(),
&DatabaseRuntimeConfig::default(),
true,
)
.expect("build_config_from");
assert!(config.introspection_enabled);
assert!(!config.introspection_require_auth);
}
#[test]
fn test_build_config_introspection_disabled() {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let config = build_config_from(
"postgres://localhost/test",
addr,
&ServerRuntimeConfig::default(),
&DatabaseRuntimeConfig::default(),
false,
)
.expect("build_config_from");
assert!(!config.introspection_enabled);
}
#[test]
fn test_build_config_pool_sizes_from_db_cfg() {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let db_cfg = DatabaseRuntimeConfig {
pool_min: 5,
pool_max: 50,
..Default::default()
};
let config = build_config_from(
"postgres://localhost/test",
addr,
&ServerRuntimeConfig::default(),
&db_cfg,
false,
)
.expect("build_config_from");
assert_eq!(config.pool_min_size, 5);
assert_eq!(config.pool_max_size, 50);
}
#[test]
fn test_build_config_cors_origins_from_server_cfg() {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let server_cfg = ServerRuntimeConfig {
cors: crate::config::runtime::CorsRuntimeConfig {
origins: vec!["https://example.com".to_string()],
credentials: false,
},
..Default::default()
};
let config = build_config_from(
"postgres://localhost/test",
addr,
&server_cfg,
&DatabaseRuntimeConfig::default(),
false,
)
.expect("build_config_from");
assert_eq!(config.cors_origins, ["https://example.com"]);
}
#[test]
fn test_resolve_runtime_config_database_url_from_toml() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://toml-host/testdb"
"#,
)
.unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let (db_url, _addr, _srv, _db) =
resolve_runtime_config(&toml_path, None, None, None).unwrap();
assert_eq!(db_url, "postgresql://toml-host/testdb");
});
}
#[test]
fn a_json_schema_reads_runtime_config_from_its_projects_config() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join("schema")).unwrap();
std::fs::write(
dir.path().join("fraiseql.toml"),
"[database]\nurl = \"postgresql://project-host/db\"\n",
)
.unwrap();
let schema = dir.path().join("schema/schema.json");
std::fs::write(&schema, "{}").unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let (db_url, _addr, _srv, _db) =
resolve_runtime_config(&schema, None, None, None).unwrap();
assert_eq!(db_url, "postgresql://project-host/db");
});
}
#[test]
fn an_unparseable_project_config_is_refused() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join("fraiseql.toml"), "[database\n").unwrap();
let schema = dir.path().join("schema.json");
std::fs::write(&schema, "{}").unwrap();
let err = resolve_runtime_config(&schema, Some("postgresql://h/db".into()), None, None)
.unwrap_err();
assert!(format!("{err:#}").contains("fraiseql.toml"), "{err:#}");
}
#[test]
fn test_resolve_runtime_config_cli_db_overrides_toml() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://toml-host/testdb"
"#,
)
.unwrap();
let (db_url, _addr, _srv, _db) = resolve_runtime_config(
&toml_path,
Some("postgresql://cli-host/clidb".to_string()),
None,
None,
)
.unwrap();
assert_eq!(db_url, "postgresql://cli-host/clidb");
}
#[test]
fn test_resolve_runtime_config_env_var_overrides_toml() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://toml-host/testdb"
"#,
)
.unwrap();
temp_env::with_vars([("DATABASE_URL", Some("postgresql://env-host/envdb"))], || {
let (db_url, _addr, _srv, _db) =
resolve_runtime_config(&toml_path, None, None, None).unwrap();
assert_eq!(db_url, "postgresql://env-host/envdb");
});
}
#[test]
fn test_resolve_runtime_config_toml_port_used_when_cli_absent() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://localhost/db"
[server]
host = "127.0.0.1"
port = 9999
"#,
)
.unwrap();
temp_env::with_vars(
[
("DATABASE_URL", None::<&str>),
("FRAISEQL_HOST", None::<&str>),
("FRAISEQL_PORT", None::<&str>),
],
|| {
let (_db_url, addr, _srv, _db) =
resolve_runtime_config(&toml_path, None, None, None).unwrap();
assert_eq!(addr.port(), 9999);
assert_eq!(addr.ip().to_string(), "127.0.0.1");
},
);
}
#[test]
fn test_resolve_runtime_config_cli_port_overrides_toml() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://localhost/db"
[server]
port = 9999
"#,
)
.unwrap();
temp_env::with_vars(
[
("DATABASE_URL", None::<&str>),
("FRAISEQL_PORT", None::<&str>),
],
|| {
let (_db_url, addr, _srv, _db) =
resolve_runtime_config(&toml_path, None, Some(7777), None).unwrap();
assert_eq!(addr.port(), 7777);
},
);
}
#[test]
fn test_resolve_runtime_config_invalid_primary_toml_is_fatal() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(&toml_path, "this is [not valid toml !!!").unwrap();
let result = resolve_runtime_config(&toml_path, None, None, None);
assert!(result.is_err(), "invalid primary TOML must be fatal");
}
#[test]
fn test_resolve_runtime_config_port_zero_rejected() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://localhost/db"
[server]
port = 0
"#,
)
.unwrap();
temp_env::with_vars(
[
("DATABASE_URL", None::<&str>),
("FRAISEQL_PORT", None::<&str>),
],
|| {
let result = resolve_runtime_config(&toml_path, None, None, None);
let msg = result.expect_err("expected Err for port=0").to_string();
assert!(msg.contains("port"), "got: {msg}");
},
);
}
#[test]
fn test_resolve_runtime_config_pool_range_rejected() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
[database]
url = "postgresql://localhost/db"
pool_min = 50
pool_max = 10
"#,
)
.unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let result = resolve_runtime_config(&toml_path, None, None, None);
let msg = result.expect_err("expected Err for pool_min > pool_max").to_string();
assert!(msg.contains("pool_min"), "got: {msg}");
});
}
#[test]
fn test_resolve_runtime_config_no_db_url_returns_error() {
let dir = TempDir::new().unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
r#"
[schema]
name = "test"
database_target = "postgresql"
"#,
)
.unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let result = resolve_runtime_config(&toml_path, None, None, None);
let msg = result.expect_err("expected Err for missing database URL").to_string();
assert!(msg.contains("database URL"), "got: {msg}");
});
}
}
mod sbom_tests {
use std::str::FromStr;
use super::super::sbom::*;
#[test]
fn test_sbom_format_from_str() {
assert_eq!(SbomFormat::from_str("cyclonedx").unwrap(), SbomFormat::CycloneDx);
assert_eq!(SbomFormat::from_str("cdx").unwrap(), SbomFormat::CycloneDx);
assert_eq!(SbomFormat::from_str("spdx").unwrap(), SbomFormat::Spdx);
assert!(SbomFormat::from_str("csv").is_err(), "expected Err for unknown format 'csv'");
}
#[test]
fn test_generate_cyclonedx() {
let packages = vec![
CargoLockPackage {
name: "serde".to_string(),
version: "1.0.200".to_string(),
source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
},
CargoLockPackage {
name: "tokio".to_string(),
version: "1.42.0".to_string(),
source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
},
];
let result = generate_cyclonedx("test-app", "1.0.0", &packages).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["bomFormat"], "CycloneDX");
assert_eq!(parsed["specVersion"], "1.5");
assert_eq!(parsed["metadata"]["component"]["name"], "test-app");
assert_eq!(parsed["components"].as_array().unwrap().len(), 2);
assert_eq!(parsed["components"][0]["name"], "serde");
assert!(
parsed["components"][0]["purl"]
.as_str()
.unwrap()
.contains("pkg:cargo/serde@1.0.200")
);
}
#[test]
fn test_generate_spdx() {
let packages = vec![CargoLockPackage {
name: "anyhow".to_string(),
version: "1.0.0".to_string(),
source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
}];
let result = generate_spdx("test-app", "0.1.0", &packages).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
assert_eq!(parsed["packages"].as_array().unwrap().len(), 1);
assert_eq!(parsed["packages"][0]["name"], "anyhow");
}
#[test]
fn test_find_cargo_lock() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
let cargo_lock = workspace_root.join("Cargo.lock");
assert!(cargo_lock.exists(), "Should find Cargo.lock in workspace root");
}
#[test]
fn test_parse_cargo_lock() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
let cargo_lock = workspace_root.join("Cargo.lock");
let content = std::fs::read_to_string(&cargo_lock).unwrap();
let packages = parse_cargo_lock_content(&content).unwrap();
assert!(!packages.is_empty(), "Cargo.lock should contain packages");
let has_serde = packages.iter().any(|p| p.name == "serde");
assert!(has_serde, "Should contain serde dependency");
}
#[test]
fn test_days_to_date_epoch() {
let (y, m, d) = days_to_date(0);
assert_eq!((y, m, d), (1970, 1, 1));
}
#[test]
fn test_days_to_date_known() {
let (y, m, d) = days_to_date(19_723);
assert_eq!((y, m, d), (2024, 1, 1));
}
#[test]
fn test_chrono_now_utc_format() {
let ts = chrono_now_utc();
assert!(ts.ends_with('Z'));
assert!(ts.contains('T'));
assert_eq!(ts.len(), 20);
}
}
mod validate_tests {
use std::io::Write;
use tempfile::NamedTempFile;
use super::super::validate::*;
fn create_valid_schema() -> String {
serde_json::json!({
"types": [
{
"name": "User",
"sql_source": "v_user",
"jsonb_column": "data",
"fields": [
{"name": "id", "field_type": "ID"},
{"name": "profile", "field_type": {"Object": "Profile"}, "nullable": true}
],
"implements": []
},
{
"name": "Profile",
"sql_source": "v_profile",
"jsonb_column": "data",
"fields": [
{"name": "bio", "field_type": "String", "nullable": true}
],
"implements": []
}
],
"queries": [
{
"name": "users",
"sql_source": "v_user",
"return_type": "[User]",
"arguments": [],
"max_results": 1000
}
],
"mutations": [],
"subscriptions": [],
"enums": [],
"input_types": [],
"interfaces": [],
"unions": [],
"directives": [],
"observers": []
})
.to_string()
}
fn create_schema_with_cycle() -> String {
serde_json::json!({
"types": [
{
"name": "A",
"sql_source": "v_a",
"jsonb_column": "data",
"fields": [
{"name": "id", "field_type": "ID"},
{"name": "b", "field_type": {"Object": "B"}}
],
"implements": []
},
{
"name": "B",
"sql_source": "v_b",
"jsonb_column": "data",
"fields": [
{"name": "id", "field_type": "ID"},
{"name": "a", "field_type": {"Object": "A"}}
],
"implements": []
}
],
"queries": [
{
"name": "items",
"sql_source": "v_a",
"return_type": "[A]",
"arguments": [],
"max_results": 1000
}
],
"mutations": [],
"subscriptions": [],
"enums": [],
"input_types": [],
"interfaces": [],
"unions": [],
"directives": [],
"observers": []
})
.to_string()
}
fn create_schema_with_unused() -> String {
serde_json::json!({
"types": [
{
"name": "User",
"sql_source": "v_user",
"jsonb_column": "data",
"fields": [
{"name": "id", "field_type": "ID"}
],
"implements": []
},
{
"name": "OrphanType",
"sql_source": "v_orphan",
"jsonb_column": "data",
"fields": [
{"name": "data", "field_type": "String"}
],
"implements": []
}
],
"queries": [
{
"name": "users",
"sql_source": "v_user",
"return_type": "[User]",
"arguments": [],
"max_results": 1000
}
],
"mutations": [],
"subscriptions": [],
"enums": [],
"input_types": [],
"interfaces": [],
"unions": [],
"directives": [],
"observers": []
})
.to_string()
}
#[test]
fn test_validate_valid_schema() {
let schema = create_valid_schema();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: true,
strict: false,
filter_types: vec![],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "success");
}
#[test]
fn test_validate_detects_cycles() {
let schema = create_schema_with_cycle();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: false,
strict: false,
filter_types: vec![],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "validation-failed");
assert!(result.errors.iter().any(|e| e.contains("Circular")));
}
#[test]
fn test_validate_cycles_disabled() {
let schema = create_schema_with_cycle();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: false,
check_unused: false,
strict: false,
filter_types: vec![],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "success");
}
#[test]
fn test_validate_unused_as_warning() {
let schema = create_schema_with_unused();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: true,
strict: false,
filter_types: vec![],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "success");
assert!(!result.warnings.is_empty());
assert!(result.warnings.iter().any(|w| w.contains("OrphanType")));
}
#[test]
fn test_validate_strict_mode() {
let schema = create_schema_with_unused();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: true,
strict: true,
filter_types: vec![],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "validation-failed");
assert!(result.errors.iter().any(|e| e.contains("OrphanType")));
}
#[test]
fn test_validate_type_filter() {
let schema = create_valid_schema();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: false,
strict: false,
filter_types: vec!["User".to_string()],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "success");
let data = result.data.unwrap();
let type_analysis = data.get("type_analysis").unwrap().as_array().unwrap();
assert_eq!(type_analysis.len(), 1);
assert_eq!(type_analysis[0]["name"], "User");
}
#[test]
fn test_validate_type_filter_not_found() {
let schema = create_valid_schema();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions {
check_cycles: true,
check_unused: false,
strict: false,
filter_types: vec!["NonExistent".to_string()],
};
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
assert_eq!(result.status, "success");
assert!(result.warnings.iter().any(|w| w.contains("NonExistent")));
}
#[test]
fn test_validate_result_structure() {
let schema = create_valid_schema();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(schema.as_bytes()).unwrap();
let opts = ValidateOptions::default();
let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
let data = result.data.unwrap();
assert!(data.get("schema_path").is_some());
assert!(data.get("valid").is_some());
assert!(data.get("type_count").is_some());
assert!(data.get("query_count").is_some());
assert!(data.get("mutation_count").is_some());
}
}
mod validate_documents_tests {
use sha2::{Digest, Sha256};
use super::super::validate_documents::*;
#[test]
fn test_rejects_manifest_exceeding_size_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.json");
let size = usize::try_from(MAX_MANIFEST_BYTES).unwrap() + 1;
std::fs::write(&path, vec![b'x'; size]).unwrap();
let formatter = crate::output::OutputFormatter::new(false, false);
let result = run(path.to_str().unwrap(), None, None, &formatter);
let msg = result.expect_err("expected Err for oversized manifest").to_string();
assert!(msg.contains("too large"), "expected size error, got: {msg}");
}
#[test]
fn test_rejects_unknown_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let manifest = serde_json::json!({
"version": 99,
"documents": {}
});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
let formatter = crate::output::OutputFormatter::new(false, false);
let result = run(path.to_str().unwrap(), None, None, &formatter);
let msg = result.expect_err("expected Err for unknown manifest version").to_string();
assert!(
msg.contains("Unsupported manifest version"),
"expected version error, got: {msg}"
);
}
#[test]
fn valid_manifest_passes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let query = "{ users { id } }";
let hash = hex::encode(Sha256::digest(query.as_bytes()));
let manifest = serde_json::json!({
"version": 1,
"documents": {
format!("sha256:{hash}"): query
}
});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
let formatter = crate::output::OutputFormatter::new(false, false);
let result = run(path.to_str().unwrap(), None, None, &formatter).unwrap();
assert!(result);
}
#[test]
fn mismatched_hash_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let manifest = serde_json::json!({
"version": 1,
"documents": {
"sha256:0000000000000000000000000000000000000000000000000000000000000000": "{ users { id } }"
}
});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
let formatter = crate::output::OutputFormatter::new(false, false);
let result = run(path.to_str().unwrap(), None, None, &formatter).unwrap();
assert!(!result);
}
#[test]
fn invalid_hash_length_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let manifest = serde_json::json!({
"version": 1,
"documents": {
"sha256:tooshort": "{ users { id } }"
}
});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
let formatter = crate::output::OutputFormatter::new(false, false);
let result = run(path.to_str().unwrap(), None, None, &formatter).unwrap();
assert!(!result);
}
}
mod validate_facts_tests {
use fraiseql_core::compiler::fact_table::{
DimensionColumn, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
};
use super::super::validate_facts::*;
#[test]
fn test_validation_issue_error() {
let issue = ValidationIssue::error("tf_sales".to_string(), "Table not found".to_string());
assert_eq!(issue.severity, IssueSeverity::Error);
assert_eq!(issue.table_name, "tf_sales");
}
#[test]
fn test_validation_issue_warning() {
let issue = ValidationIssue::warning(
"tf_orders".to_string(),
"Table exists but not declared".to_string(),
);
assert_eq!(issue.severity, IssueSeverity::Warning);
}
fn make_metadata(
measures: Vec<MeasureColumn>,
dim_name: &str,
filters: Vec<FilterColumn>,
) -> FactTableMetadata {
FactTableMetadata {
table_name: "tf_sales".to_string(),
type_name: None,
measures,
dimensions: DimensionColumn {
name: dim_name.to_string(),
paths: vec![],
},
denormalized_filters: filters,
calendar_dimensions: vec![],
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
}
}
#[test]
fn test_compare_metadata_matching() {
let declared = make_metadata(
vec![
MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
MeasureColumn {
name: "quantity".to_string(),
sql_type: SqlType::Int,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
],
"data",
vec![FilterColumn {
name: "customer_id".to_string(),
sql_type: SqlType::Uuid,
indexed: true,
hierarchy: None,
}],
);
let actual = declared.clone();
let issues = compare_metadata("tf_sales", &declared, &actual);
let errors: Vec<_> = issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
assert!(errors.is_empty(), "Unexpected errors: {errors:?}");
}
#[test]
fn test_compare_metadata_missing_measure() {
let declared = make_metadata(
vec![
MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
MeasureColumn {
name: "profit".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
},
],
"data",
vec![],
);
let actual = make_metadata(
vec![MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
additivity: fraiseql_core::compiler::fact_table::Additivity::Additive,
}],
"data",
vec![],
);
let issues = compare_metadata("tf_sales", &declared, &actual);
let errors: Vec<_> = issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("profit"));
}
}
mod query_tests {
use serde_json::json;
use super::super::query::{has_errors, parse_variables};
#[test]
fn parse_variables_none_is_none() {
assert!(parse_variables(None).unwrap().is_none());
}
#[test]
fn parse_variables_valid_object() {
let v = parse_variables(Some(r#"{"id": 42}"#)).unwrap().unwrap();
assert_eq!(v, json!({"id": 42}));
}
#[test]
fn parse_variables_rejects_non_object() {
assert!(parse_variables(Some("42")).is_err());
assert!(parse_variables(Some("[1, 2]")).is_err());
}
#[test]
fn parse_variables_rejects_invalid_json() {
assert!(parse_variables(Some("{not json")).is_err());
}
#[test]
fn has_errors_detects_non_empty_errors_array() {
assert!(has_errors(&json!({"data": null, "errors": [{"message": "boom"}]})));
}
#[test]
fn has_errors_false_for_clean_response() {
assert!(!has_errors(&json!({"data": {"users": []}})));
assert!(!has_errors(&json!({"data": {}, "errors": []})));
}
}
mod doctor_runtime_tests {
use fraiseql_core::schema::{
ArgumentDefinition, CompiledSchema, FieldDefinition, FieldType, MutationDefinition,
QueryDefinition, TypeDefinition, UnionDefinition,
};
use super::super::doctor::{
minimal_mutation_probe, minimal_query_probe, minimal_selection_for_type,
probe_requires_arguments,
};
fn user_type() -> TypeDefinition {
let mut t = TypeDefinition::new("User", "v_user");
t.fields.push(FieldDefinition::new("id", FieldType::Id));
t.fields.push(FieldDefinition::new("name", FieldType::String));
t
}
#[test]
fn requires_arguments_true_for_required_arg() {
let args = vec![ArgumentDefinition::new("id", FieldType::Id)];
assert!(probe_requires_arguments(&args));
}
#[test]
fn requires_arguments_false_for_optional_or_empty() {
assert!(!probe_requires_arguments(&[]));
let args = vec![ArgumentDefinition::optional("limit", FieldType::Int)];
assert!(!probe_requires_arguments(&args));
}
#[test]
fn minimal_selection_picks_first_scalar_field() {
let mut schema = CompiledSchema::new();
schema.types.push(user_type());
assert_eq!(minimal_selection_for_type("User", &schema).as_deref(), Some("id"));
}
#[test]
fn minimal_selection_falls_back_to_typename() {
let mut schema = CompiledSchema::new();
schema.types.push(TypeDefinition::new("Empty", "v_empty"));
assert_eq!(minimal_selection_for_type("Empty", &schema).as_deref(), Some("__typename"));
}
#[test]
fn minimal_selection_none_for_leaf_type() {
let schema = CompiledSchema::new(); assert!(minimal_selection_for_type("Int", &schema).is_none());
}
#[test]
fn minimal_selection_uses_typename_for_a_union() {
let mut schema = CompiledSchema::new();
schema.unions.push(UnionDefinition {
name: "CreateUserResult".into(),
member_types: vec!["User".into()],
description: None,
});
assert_eq!(
minimal_selection_for_type("CreateUserResult", &schema).as_deref(),
Some("__typename")
);
}
#[test]
fn mutation_probe_for_a_union_payload_carries_a_selection_set() {
let mut schema = CompiledSchema::new();
schema.unions.push(UnionDefinition {
name: "CreateUserResult".into(),
member_types: vec!["User".into()],
description: None,
});
schema.mutations.push(MutationDefinition::new("createUser", "CreateUserResult"));
let op = minimal_mutation_probe(&schema.mutations[0], &schema).unwrap();
assert_eq!(op, "mutation { createUser { __typename } }");
}
#[test]
fn query_probe_for_object_return() {
let mut schema = CompiledSchema::new();
schema.types.push(user_type());
schema.queries.push(QueryDefinition::new("users", "User"));
let op = minimal_query_probe(&schema.queries[0], &schema).unwrap();
assert_eq!(op, "{ users { id } }");
}
#[test]
fn query_probe_for_scalar_return_has_no_subselection() {
let mut schema = CompiledSchema::new();
schema.queries.push(QueryDefinition::new("count", "Int"));
let op = minimal_query_probe(&schema.queries[0], &schema).unwrap();
assert_eq!(op, "{ count }");
}
#[test]
fn query_probe_skipped_when_required_arg() {
let mut schema = CompiledSchema::new();
schema.types.push(user_type());
let mut q = QueryDefinition::new("user", "User");
q.arguments.push(ArgumentDefinition::new("id", FieldType::Id));
schema.queries.push(q);
assert!(minimal_query_probe(&schema.queries[0], &schema).is_none());
}
#[test]
fn mutation_probe_dry_run_shape() {
let mut schema = CompiledSchema::new();
schema.types.push(user_type());
schema.mutations.push(MutationDefinition::new("ping", "User"));
let op = minimal_mutation_probe(&schema.mutations[0], &schema).unwrap();
assert_eq!(op, "mutation { ping { id } }");
}
#[test]
fn mutation_probe_skipped_when_required_arg() {
let mut schema = CompiledSchema::new();
let mut m = MutationDefinition::new("createUser", "User");
m.arguments.push(ArgumentDefinition::new("name", FieldType::String));
schema.mutations.push(m);
assert!(minimal_mutation_probe(&schema.mutations[0], &schema).is_none());
}
}
#[test]
fn ddl_snake_case_is_acronym_aware() {
for (input, expected) in [
("ID", "id"),
("HTTPServer", "http_server"),
("IOError", "io_error"),
("User", "user"),
("UserProfile", "user_profile"),
("userID", "user_id"),
("s3Bucket", "s3_bucket"),
("oauth2Token", "oauth2_token"),
] {
assert_eq!(
super::compile::to_snake_case(input),
expected,
"{input} must lower to {expected}"
);
}
}
#[test]
fn ddl_snake_case_matches_the_pipeline_namer() {
for name in [
"ID",
"HTTPServer",
"OAuth2Token",
"oauth2Token",
"IOError",
"User",
"UserProfile",
"userID",
"phone1",
"s3Bucket",
] {
assert_eq!(
super::compile::to_snake_case(name),
fraiseql_core::utils::to_snake_case(name),
"the DDL namer disagrees with the pipeline namer about {name:?}"
);
}
}
mod compile_database_url_tests {
use super::super::compile::{CompileOptions, compile_to_schema};
#[tokio::test]
async fn a_removed_engine_url_is_refused_naming_postgresql_only() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("schema.toml");
std::fs::write(&input, "[schema]\nname = \"a\"\nversion = \"1\"\n").unwrap();
let input = input.to_str().unwrap();
for url in ["sqlite://x.db", "mysql://localhost/db", "mssql://h/db"] {
let err = compile_to_schema(CompileOptions::new(input).with_database(url))
.await
.map(|_| ())
.expect_err(url);
let err = format!("{err:#}");
assert!(err.contains("PostgreSQL-only"), "{url}: {err}");
}
}
}
mod default_collision_tests {
use super::super::compile::{default_caused, default_collision};
use crate::schema::mutation_contract::ContractViolation;
fn added(keys: &[&str]) -> Vec<String> {
keys.iter().map(ToString::to_string).collect()
}
#[test]
fn an_arity_short_by_the_default_supplied_parameters_is_the_defaults() {
let v = ContractViolation::ArityMismatch {
expected: 4,
found: vec![3],
};
assert_eq!(default_caused(&v, &added(&["org_id"])).as_deref(), Some("org_id"));
}
#[test]
fn an_arity_the_default_does_not_explain_is_not_attributed() {
let v = ContractViolation::ArityMismatch {
expected: 4,
found: vec![1],
};
assert_eq!(default_caused(&v, &added(&["org_id"])), None);
let v = ContractViolation::ArityMismatch {
expected: 4,
found: vec![3],
};
assert_eq!(default_caused(&v, &[]), None, "no default, no attribution");
}
#[test]
fn a_name_mismatch_on_a_default_supplied_parameter_is_the_defaults() {
let v = ContractViolation::InjectNameMismatch {
position: 1,
expected: "org_id".into(),
actual: "p_org".into(),
};
assert_eq!(default_caused(&v, &added(&["org_id"])).as_deref(), Some("org_id"));
assert_eq!(default_caused(&v, &added(&["user_id"])), None);
}
#[test]
fn the_collision_names_the_default_the_count_and_the_way_out() {
let mutations: Vec<String> = (1..=7).map(|i| format!("m{i}")).collect();
let message = default_collision("org_id", &mutations);
assert!(message.contains("adds `org_id` to 7 mutation(s)"), "{message}");
assert!(message.contains("`m5`, and 2 more"), "{message}");
assert!(message.contains("exclude_inject_defaults"), "{message}");
}
}
#[cfg(test)]
mod declared_fact_tables_tests {
use super::super::validate_facts::declared_fact_tables;
use crate::schema::{IntermediateSchema, SchemaConverter};
const AUTHORING: &str = r#"{"fact_tables": [{"table_name": "tf_sales", "measures": [],
"dimensions": {"name": "data", "paths": []}, "denormalized_filters": []}]}"#;
fn names(schema_json: &str) -> Vec<String> {
let mut names: Vec<String> =
declared_fact_tables(schema_json).expect("a valid schema").into_keys().collect();
names.sort();
names
}
#[test]
fn an_authoring_schema_declares_its_fact_tables() {
assert_eq!(names(AUTHORING), ["tf_sales"]);
}
#[test]
fn a_compiled_schema_declares_its_fact_tables() {
let intermediate: IntermediateSchema =
serde_json::from_str(AUTHORING).expect("the authoring fixture parses");
let compiled = SchemaConverter::convert(intermediate).expect("the fixture compiles");
let json = compiled.to_json().expect("the compiled schema serializes");
assert_eq!(names(&json), ["tf_sales"]);
}
#[test]
fn a_malformed_schema_is_an_error_not_zero_tables() {
let result = declared_fact_tables(r#"{"fact_tables": 5}"#);
assert!(result.is_err(), "a schema neither format accepts must fail, got {result:?}");
}
}