use fraiseql_core::schema::{
ArgumentDefinition, CompiledSchema, FieldType, InjectedParamSource, InputFieldDefinition,
InputObjectDefinition, MutationDefinition, MutationOperation,
};
use indexmap::IndexMap;
use super::*;
use crate::schema::pg_catalog::{OutColumn, PgFunction};
fn single_input_arg(type_name: &str) -> ArgumentDefinition {
ArgumentDefinition::new("input", FieldType::Input(type_name.to_string()))
}
fn input_type(name: &str, field_count: usize) -> InputObjectDefinition {
let fields = (0..field_count)
.map(|i| InputFieldDefinition::new(format!("f{i}"), "String"))
.collect();
InputObjectDefinition::new(name).with_fields(fields)
}
fn inject(keys: &[&str]) -> IndexMap<String, InjectedParamSource> {
keys.iter()
.map(|k| ((*k).to_string(), InjectedParamSource::Jwt((*k).to_string())))
.collect()
}
fn col(name: &str, type_name: &str) -> OutColumn {
OutColumn {
name: name.to_string(),
type_name: type_name.to_string(),
is_enum: false,
}
}
fn full_response_columns() -> Vec<OutColumn> {
vec![
col("succeeded", "boolean"),
col("state_changed", "boolean"),
col("error_class", "text"),
col("status_detail", "text"),
col("http_status", "smallint"),
col("message", "text"),
col("entity_id", "uuid"),
col("entity_type", "text"),
col("entity", "jsonb"),
col("updated_fields", "text[]"),
col("cascade", "jsonb"),
col("error_detail", "jsonb"),
col("metadata", "jsonb"),
]
}
fn pg_function(in_types: &[&str], in_names: &[Option<&str>], out: Vec<OutColumn>) -> PgFunction {
PgFunction {
schema: "public".to_string(),
name: "fn_test".to_string(),
in_types: in_types.iter().map(|s| (*s).to_string()).collect(),
in_names: in_names.iter().map(|n| n.map(str::to_string)).collect(),
out_columns: out,
returns_set: true,
volatility: "v".to_string(),
language: "plpgsql".to_string(),
source: String::new(),
}
}
#[test]
fn update_single_input_is_jsonb_payload_even_without_input_type_in_schema() {
let mut m = MutationDefinition::new("updateUser", "UpdateUserResult");
m.sql_source = Some("fn_update_user".to_string());
m.operation = MutationOperation::Update {
table: "tb_user".to_string(),
};
m.arguments = vec![single_input_arg("UpdateUserInput")];
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("update mutation is DB-backed");
assert_eq!(ec.shape, CallShape::JsonbPayload);
assert_eq!(ec.base_arity, 1);
assert!(ec.first_is_jsonb_payload);
assert_eq!(ec.total_arity(), 1);
}
#[test]
fn insert_single_input_flattens_input_type_fields() {
let mut m = MutationDefinition::new("createUser", "CreateUserResult");
m.sql_source = Some("fn_create_user".to_string());
m.operation = MutationOperation::Insert {
table: "tb_user".to_string(),
};
m.arguments = vec![single_input_arg("CreateUserInput")];
let schema = CompiledSchema {
input_types: vec![input_type("CreateUserInput", 3)],
..Default::default()
};
let ec = expected_call(&m, &schema).expect("insert mutation is DB-backed");
assert_eq!(ec.shape, CallShape::FlattenedFields);
assert_eq!(ec.base_arity, 3);
assert!(!ec.first_is_jsonb_payload);
}
#[test]
fn insert_single_input_takes_jsonb_path_when_type_missing() {
let mut m = MutationDefinition::new("createUser", "CreateUserResult");
m.sql_source = Some("fn_create_user".to_string());
m.operation = MutationOperation::Insert {
table: String::new(),
};
m.arguments = vec![single_input_arg("CreateUserInput")];
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("DB-backed");
assert_eq!(ec.shape, CallShape::JsonbPayload);
assert_eq!(ec.base_arity, 1);
assert!(ec.first_is_jsonb_payload);
}
#[test]
fn flat_args_count_each_argument() {
let mut m = MutationDefinition::new("archive", "ArchiveResult");
m.sql_source = Some("fn_archive".to_string());
m.arguments = vec![
ArgumentDefinition::new("id", FieldType::Uuid),
ArgumentDefinition::new("reason", FieldType::String),
];
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("DB-backed");
assert_eq!(ec.shape, CallShape::FlatArgs);
assert_eq!(ec.base_arity, 2);
}
#[test]
fn single_input_with_non_input_type_is_flat() {
let mut m = MutationDefinition::new("touch", "TouchResult");
m.sql_source = Some("fn_touch".to_string());
m.arguments = vec![ArgumentDefinition::new("input", FieldType::String)];
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("DB-backed");
assert_eq!(ec.shape, CallShape::FlatArgs);
assert_eq!(ec.base_arity, 1);
}
#[test]
fn inject_params_extend_total_arity_in_order() {
let mut m = MutationDefinition::new("updateUser", "UpdateUserResult");
m.sql_source = Some("fn_update_user".to_string());
m.operation = MutationOperation::Update {
table: "tb_user".to_string(),
};
m.arguments = vec![single_input_arg("UpdateUserInput")];
m.inject_params = inject(&["tenant_id", "sub"]);
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("DB-backed");
assert_eq!(ec.base_arity, 1);
assert_eq!(ec.inject_names, vec!["tenant_id".to_string(), "sub".to_string()]);
assert_eq!(ec.total_arity(), 3);
}
#[test]
fn non_db_backed_mutation_is_skipped() {
let mut m = MutationDefinition::new("notifyExternal", "NotifyResult");
m.operation = MutationOperation::Custom;
let schema = CompiledSchema::default();
assert!(expected_call(&m, &schema).is_none());
}
#[test]
fn falls_back_to_operation_table_when_sql_source_absent() {
let mut m = MutationDefinition::new("createUser", "CreateUserResult");
m.operation = MutationOperation::Insert {
table: "fn_create_user".to_string(),
};
let schema = CompiledSchema::default();
let ec = expected_call(&m, &schema).expect("table fallback is DB-backed");
assert_eq!(ec.sql_source, "fn_create_user");
}
fn check(
m: &MutationDefinition,
schema: &CompiledSchema,
candidates: &[PgFunction],
) -> Vec<ContractViolation> {
check_mutation(&expected_call(m, schema).expect("DB-backed mutation"), candidates)
}
fn order_mutation(
op: MutationOperation,
style: fraiseql_core::schema::InputStyle,
) -> MutationDefinition {
let mut m = MutationDefinition::new("createOrder", "CreateOrderResult");
m.sql_source = Some("fn_create_order".to_string());
m.operation = op;
m.input_style = style;
m.arguments = vec![single_input_arg("CreateOrderInput")];
m
}
fn schema_with_order_input(fields: usize) -> CompiledSchema {
CompiledSchema {
input_types: vec![input_type("CreateOrderInput", fields)],
..Default::default()
}
}
#[test]
fn jsonb_insert_single_input_no_false_arity_mismatch() {
use fraiseql_core::schema::InputStyle;
let m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Jsonb,
);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["jsonb"],
&[Some("p_input")],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "single-jsonb insert must be clean");
}
#[test]
fn jsonb_insert_with_inject_param_no_violations() {
use fraiseql_core::schema::InputStyle;
let mut m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Jsonb,
);
m.inject_params = inject(&["tenant_id"]);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["jsonb", "uuid"],
&[Some("p_input"), Some("tenant_id")],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "jsonb + inject must be clean");
}
#[test]
fn jsonb_insert_against_flattened_function_is_arity_mismatch() {
use fraiseql_core::schema::InputStyle;
let m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Jsonb,
);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["int4", "int4", "int4", "int4", "int4", "int4"],
&[None, None, None, None, None, None],
full_response_columns(),
)];
assert_eq!(
check(&m, &schema, &candidates),
vec![ContractViolation::ArityMismatch {
expected: 1,
found: vec![6],
}],
);
}
#[test]
fn flatten_insert_is_unchanged() {
use fraiseql_core::schema::InputStyle;
let m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Flatten,
);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["text", "text", "text", "text", "text", "text"],
&[None, None, None, None, None, None],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "flatten path unchanged");
}
#[test]
fn update_with_flatten_style_still_takes_jsonb_path() {
use fraiseql_core::schema::InputStyle;
let m = order_mutation(
MutationOperation::Update {
table: "tb_order".to_string(),
},
InputStyle::Flatten,
);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["jsonb"],
&[Some("p_input")],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "update→jsonb preserved");
}
#[test]
fn jsonb_payload_wrong_type_is_flagged() {
use fraiseql_core::schema::InputStyle;
let m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Jsonb,
);
let schema = schema_with_order_input(6);
let candidates = vec![pg_function(
&["text"],
&[Some("p_input")],
full_response_columns(),
)];
assert!(
check(&m, &schema, &candidates).contains(&ContractViolation::PayloadNotJsonb {
actual: "text".to_string(),
}),
"single-jsonb path must assert arg-1 is jsonb",
);
}
#[test]
fn custom_op_jsonb_style_is_not_update_only() {
use fraiseql_core::schema::{InputStyle, MutationOperation as Op};
let mut m = MutationDefinition::new("archiveOrder", "ArchiveOrderResult");
m.sql_source = Some("fn_archive_order".to_string());
m.operation = Op::Custom;
m.input_style = InputStyle::Jsonb;
m.arguments = vec![single_input_arg("ArchiveOrderInput")];
let schema = CompiledSchema {
input_types: vec![input_type("ArchiveOrderInput", 3)],
..Default::default()
};
let candidates = vec![pg_function(
&["jsonb"],
&[Some("p_input")],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "custom + jsonb must be clean");
}
#[test]
fn two_scalar_args_with_jsonb_style_stay_flat() {
use fraiseql_core::schema::InputStyle;
let mut m = MutationDefinition::new("createOrder", "CreateOrderResult");
m.sql_source = Some("fn_create_order".to_string());
m.operation = MutationOperation::Insert {
table: "tb_order".to_string(),
};
m.input_style = InputStyle::Jsonb;
m.arguments = vec![
ArgumentDefinition::new("a", FieldType::Int),
ArgumentDefinition::new("b", FieldType::Int),
];
let schema = CompiledSchema::default();
let candidates = vec![pg_function(
&["int4", "int4"],
&[None, None],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "two scalar args stay FlatArgs(2)");
}
#[test]
fn preserve_naming_jsonb_single_input_holds() {
use fraiseql_core::schema::{InputStyle, NamingConvention};
let m = order_mutation(
MutationOperation::Insert {
table: "tb_order".to_string(),
},
InputStyle::Jsonb,
);
let schema = CompiledSchema {
input_types: vec![input_type("CreateOrderInput", 6)],
naming_convention: NamingConvention::Preserve,
..Default::default()
};
let candidates = vec![pg_function(
&["jsonb"],
&[Some("p_input")],
full_response_columns(),
)];
assert_eq!(check(&m, &schema, &candidates), vec![], "arg-1-is-jsonb holds under Preserve");
}
fn jsonb_update_call() -> ExpectedCall {
ExpectedCall {
sql_source: "fn_update_user".to_string(),
shape: CallShape::JsonbPayload,
base_arity: 1,
inject_names: vec!["tenant_id".to_string()],
first_is_jsonb_payload: true,
payload_keys: vec![],
stamps: None,
requires_result: false,
}
}
#[test]
fn missing_function_when_no_candidates() {
let v = check_mutation(&jsonb_update_call(), &[]);
assert_eq!(v, vec![ContractViolation::MissingFunction]);
}
#[test]
fn arity_mismatch_reports_found_arities() {
let candidates = vec![pg_function(
&["jsonb"],
&[Some("input")],
full_response_columns(),
)];
let v = check_mutation(&jsonb_update_call(), &candidates);
assert_eq!(
v,
vec![ContractViolation::ArityMismatch {
expected: 2,
found: vec![1],
}]
);
}
#[test]
fn ambiguous_when_two_overloads_match_arity() {
let f = || {
pg_function(
&["jsonb", "uuid"],
&[Some("input"), Some("tenant_id")],
full_response_columns(),
)
};
let v = check_mutation(&jsonb_update_call(), &[f(), f()]);
assert_eq!(v, vec![ContractViolation::AmbiguousFunction { arity: 2, count: 2 }]);
}
#[test]
fn payload_not_jsonb_flagged() {
let candidates = vec![pg_function(
&["text", "uuid"],
&[Some("input"), Some("tenant_id")],
full_response_columns(),
)];
let v = check_mutation(&jsonb_update_call(), &candidates);
assert!(v.contains(&ContractViolation::PayloadNotJsonb {
actual: "text".to_string(),
}));
}
#[test]
fn happy_path_has_no_violations() {
let candidates = vec![pg_function(
&["jsonb", "uuid"],
&[Some("input"), Some("tenant_id")],
full_response_columns(),
)];
let v = check_mutation(&jsonb_update_call(), &candidates);
assert!(v.is_empty(), "expected no violations, got {v:?}");
}
#[test]
fn inject_name_mismatch_is_a_warning() {
let candidates = vec![pg_function(
&["jsonb", "uuid"],
&[Some("input"), Some("org_id")], full_response_columns(),
)];
let v = check_mutation(&jsonb_update_call(), &candidates);
let mismatch = v
.iter()
.find(|x| matches!(x, ContractViolation::InjectNameMismatch { .. }))
.expect("inject name mismatch reported");
assert_eq!(mismatch.severity(), Severity::Warn);
}
#[test]
fn unnamed_inject_param_position_is_not_flagged() {
let candidates = vec![pg_function(
&["jsonb", "uuid"],
&[None, None],
full_response_columns(),
)];
let v = check_mutation(&jsonb_update_call(), &candidates);
assert!(
!v.iter().any(|x| matches!(x, ContractViolation::InjectNameMismatch { .. })),
"unnamed params should not trigger an inject-name mismatch: {v:?}"
);
}
fn flat_call() -> ExpectedCall {
ExpectedCall {
sql_source: "fn_create".to_string(),
shape: CallShape::FlatArgs,
base_arity: 1,
inject_names: vec![],
first_is_jsonb_payload: false,
payload_keys: vec![],
stamps: None,
requires_result: false,
}
}
#[test]
fn missing_succeeded_column_is_an_error() {
let mut cols = full_response_columns();
cols.retain(|c| c.name != "succeeded");
let candidates = vec![pg_function(&["jsonb"], &[Some("input")], cols)];
let v = check_mutation(&flat_call(), &candidates);
let missing = v
.iter()
.find(|x| {
matches!(
x,
ContractViolation::MissingRequiredColumn {
column: "succeeded",
}
)
})
.expect("missing succeeded reported");
assert_eq!(missing.severity(), Severity::Error);
}
#[test]
fn wrong_succeeded_type_is_an_error() {
let mut cols = full_response_columns();
cols[0] = col("succeeded", "text");
let candidates = vec![pg_function(&["jsonb"], &[Some("input")], cols)];
let v = check_mutation(&flat_call(), &candidates);
assert!(v.contains(&ContractViolation::RequiredColumnWrongType {
column: "succeeded",
actual: "text".to_string(),
}));
}
#[test]
fn optional_column_wrong_type_is_a_warning() {
let mut cols = full_response_columns();
if let Some(c) = cols.iter_mut().find(|c| c.name == "entity") {
c.type_name = "text".to_string();
}
let candidates = vec![pg_function(&["jsonb"], &[Some("input")], cols)];
let v = check_mutation(&flat_call(), &candidates);
let warn = v
.iter()
.find(|x| {
matches!(
x,
ContractViolation::OptionalColumnWrongType {
column: "entity",
..
}
)
})
.expect("entity type warning reported");
assert_eq!(warn.severity(), Severity::Warn);
}
#[test]
fn error_class_enum_is_accepted() {
let mut cols = full_response_columns();
if let Some(c) = cols.iter_mut().find(|c| c.name == "error_class") {
c.type_name = "app.mutation_error_class".to_string();
c.is_enum = true;
}
let candidates = vec![pg_function(&["jsonb"], &[Some("input")], cols)];
let v = check_mutation(&flat_call(), &candidates);
assert!(
!v.iter().any(|x| matches!(
x,
ContractViolation::OptionalColumnWrongType {
column: "error_class",
..
}
)),
"enum error_class must be accepted: {v:?}"
);
}
#[test]
fn scalar_return_is_unverifiable() {
let candidates = vec![pg_function(&["jsonb"], &[Some("input")], vec![])];
let v = check_mutation(&flat_call(), &candidates);
let unverifiable = v
.iter()
.find(|x| matches!(x, ContractViolation::ResponseShapeUnverifiable))
.expect("unverifiable reported");
assert_eq!(unverifiable.severity(), Severity::Warn);
}
#[test]
fn report_counts_split_by_severity() {
let mut report = ContractReport {
checked: 2,
skipped: 1,
mutations: vec![],
};
report.mutations.push(MutationReport {
mutation: "updateUser".to_string(),
sql_source: "fn_update_user".to_string(),
violations: vec![
ContractViolation::MissingRequiredColumn {
column: "succeeded",
}, ContractViolation::ResponseShapeUnverifiable, ],
});
assert_eq!(report.error_count(), 1);
assert_eq!(report.warn_count(), 1);
let json = report.to_json();
assert_eq!(json["errors"], 1);
assert_eq!(json["warnings"], 1);
assert_eq!(json["checked"], 2);
assert_eq!(json["skipped"], 1);
}
fn payload_call(keys: &[&str]) -> ExpectedCall {
ExpectedCall {
sql_source: "fn_update_thing".to_string(),
shape: CallShape::JsonbPayload,
base_arity: 1,
inject_names: vec![],
first_is_jsonb_payload: true,
payload_keys: keys.iter().map(ToString::to_string).collect(),
stamps: None,
requires_result: false,
}
}
fn payload_function(body: &str) -> PgFunction {
let mut f = pg_function(&["jsonb"], &[Some("payload")], full_response_columns());
f.source = body.to_string();
f
}
#[test]
fn payload_keys_referenced_in_body_are_clean() {
let candidates = vec![payload_function(
"BEGIN PERFORM payload->>'author'; PERFORM payload->>'title'; END",
)];
let v = check_mutation(&payload_call(&["author", "title"]), &candidates);
assert_eq!(v, vec![], "keys the body extracts must not be flagged");
}
#[test]
fn unreferenced_payload_key_is_flagged() {
let candidates = vec![payload_function("BEGIN PERFORM payload->>'author'; END")];
let v = check_mutation(&payload_call(&["author", "ownerId"]), &candidates);
assert_eq!(
v,
vec![ContractViolation::PayloadKeyUnreferenced {
field: "ownerId".to_string(),
snake_key: "owner_id".to_string(),
}]
);
}
#[test]
fn snake_case_reference_satisfies_a_camel_case_field() {
let candidates = vec![payload_function("BEGIN PERFORM payload->>'owner_id'; END")];
let v = check_mutation(&payload_call(&["ownerId"]), &candidates);
assert_eq!(v, vec![]);
}
#[test]
fn whole_payload_consumer_skips_the_scan() {
let candidates = vec![payload_function(
"BEGIN INSERT INTO tb_thing SELECT * FROM jsonb_populate_record(NULL::tb_thing, payload); \
PERFORM payload->>'author'; END",
)];
let v = check_mutation(&payload_call(&["author", "ownerId"]), &candidates);
assert_eq!(v, vec![]);
}
#[test]
fn non_sql_language_skips_the_scan() {
let mut f = payload_function("thing_update_impl");
f.language = "c".to_string();
let v = check_mutation(&payload_call(&["author"]), &[f]);
assert_eq!(v, vec![]);
}
#[test]
fn forwarding_body_without_extraction_skips_the_scan() {
let candidates = vec![payload_function(
"BEGIN PERFORM app.do_update(payload); END",
)];
let v = check_mutation(&payload_call(&["author", "ownerId"]), &candidates);
assert_eq!(v, vec![]);
}
#[test]
fn expected_call_populates_payload_keys_for_known_update_input() {
let mut m = MutationDefinition::new("updateThing", "UpdateThingResult");
m.sql_source = Some("fn_update_thing".to_string());
m.operation = MutationOperation::Update {
table: "tb_thing".to_string(),
};
m.arguments = vec![single_input_arg("UpdateThingInput")];
let schema = CompiledSchema {
input_types: vec![input_type("UpdateThingInput", 2)],
..CompiledSchema::new()
};
let call = expected_call(&m, &schema).expect("DB-backed mutation");
assert_eq!(call.payload_keys, vec!["f0".to_string(), "f1".to_string()]);
}
#[test]
fn expected_call_has_no_payload_keys_for_unknown_input_type() {
let mut m = MutationDefinition::new("updateThing", "UpdateThingResult");
m.sql_source = Some("fn_update_thing".to_string());
m.operation = MutationOperation::Update {
table: "tb_thing".to_string(),
};
m.arguments = vec![single_input_arg("NotInSchema")];
let call = expected_call(&m, &CompiledSchema::new()).expect("DB-backed mutation");
assert!(call.payload_keys.is_empty());
}
fn stamp_schema() -> (CompiledSchema, MutationDefinition) {
use fraiseql_core::schema::{TypeDefinition, UnionDefinition};
let error = |name: &str| {
let mut t = TypeDefinition::new(name, "v_error");
t.is_error = true;
t
};
let schema = CompiledSchema {
types: vec![
TypeDefinition::new("User", "v_user"),
TypeDefinition::new("Admin", "v_admin"),
error("DuplicateEmailError"),
error("ValidationError"),
],
unions: vec![UnionDefinition::new("CreateUserResult").with_members(vec![
"User".to_string(),
"DuplicateEmailError".to_string(),
"ValidationError".to_string(),
])],
..CompiledSchema::new()
};
let mut m = MutationDefinition::new("createUser", "CreateUserResult");
m.sql_source = Some("fn_create_user".to_string());
(schema, m)
}
fn stamp_errors(body: &str) -> Vec<String> {
let (schema, m) = stamp_schema();
let call = expected_call(&m, &schema).expect("DB-backed mutation");
let mut f = pg_function(&[], &[], full_response_columns());
f.source = body.to_string();
check_mutation(&call, &[f])
.iter()
.filter(|v| v.severity() == Severity::Error)
.map(ToString::to_string)
.collect()
}
#[test]
fn a_literal_success_stamp_outside_the_contract_is_an_error() {
let errors = stamp_errors(
"BEGIN RETURN fraiseql.mutation_ok(to_jsonb(v_user), v_user.id, \
p_entity_type => 'Admin'); END",
);
assert!(errors.iter().any(|e| e.contains("'Admin'")), "{errors:?}");
}
#[test]
fn a_literal_error_stamp_outside_the_contract_is_an_error() {
let errors = stamp_errors(
"BEGIN RETURN fraiseql.mutation_err('conflict', 'Email taken', NULL, 409, 'User'); END",
);
assert!(errors.iter().any(|e| e.contains("'User'")), "{errors:?}");
}
#[test]
fn an_assigned_stamp_outside_the_contract_is_an_error() {
let errors = stamp_errors("BEGIN result.entity_type := 'tb_user'; RETURN result; END");
assert!(errors.iter().any(|e| e.contains("'tb_user'")), "{errors:?}");
}
#[test]
fn stamps_inside_the_contract_and_what_is_not_a_literal_stamp_are_clean() {
for body in [
"BEGIN RETURN fraiseql.mutation_ok(to_jsonb(v), v.id, p_entity_type => 'User'); END",
"BEGIN RETURN mutation_err('conflict', 'x', p_entity_type => 'DuplicateEmailError'); END",
"BEGIN RETURN fraiseql.mutation_err('invalid', 'x', NULL, 422, 'ValidationError'); END",
"BEGIN result.entity_type := 'User'; RETURN result; END",
"BEGIN RETURN fraiseql.mutation_ok(to_jsonb(v), v.id, p_entity_type => v_type); END",
"BEGIN RETURN fraiseql.mutation_ok(to_jsonb(v), v.id, NULL); END",
"BEGIN PERFORM 1 FROM audit a WHERE a.entity_type = 'Admin'; RETURN r; END",
"BEGIN\n-- RETURN fraiseql.mutation_ok(e, i, 'Admin');\nRETURN r; END",
"BEGIN -- p_entity_type => 'Admin'\n /* result.entity_type := 'Admin'; */ RETURN r; END",
"BEGIN PERFORM app.log('mutation_ok(', e, 'Admin'); RETURN r; END",
"BEGIN PERFORM app.log_event(p_entity_type => 'Admin'); RETURN r; END",
] {
assert_eq!(stamp_errors(body), Vec::<String>::new(), "{body}");
}
}
#[test]
fn an_off_contract_stamp_names_its_outcome_and_the_set_it_allows() {
let (schema, m) = stamp_schema();
let call = expected_call(&m, &schema).expect("DB-backed mutation");
let run = |body: &str| {
let mut f = pg_function(&[], &[], full_response_columns());
f.source = body.to_string();
check_mutation(&call, &[f])
};
let errors = || {
vec![
"DuplicateEmailError".to_string(),
"ValidationError".to_string(),
]
};
assert_eq!(
run("BEGIN RETURN fraiseql.mutation_ok(to_jsonb(v), v.id, p_entity_type => 'Admin'); END"),
vec![ContractViolation::OffContractStamp {
arm: StampArm::Success,
stamp: "Admin".to_string(),
allowed: vec!["User".to_string()],
}]
);
assert_eq!(
run("BEGIN RETURN fraiseql.mutation_err('conflict', 'x', NULL, 409, 'User'); END"),
vec![ContractViolation::OffContractStamp {
arm: StampArm::Error,
stamp: "User".to_string(),
allowed: errors(),
}]
);
let mut either = vec!["User".to_string()];
either.extend(errors());
assert_eq!(
run("BEGIN r.entity_type := 'it''s'; RETURN r; END"),
vec![ContractViolation::OffContractStamp {
arm: StampArm::Either,
stamp: "it's".to_string(),
allowed: either,
}]
);
assert_eq!(
run("BEGIN IF x THEN RETURN FRAISEQL.MUTATION_OK(e, i, 'Admin'::text); END IF; \
RETURN fraiseql.mutation_ok(e, i, p_entity_type := 'Admin'); END")
.len(),
1
);
assert_eq!(
run("BEGIN EXECUTE 'SELECT fraiseql.mutation_ok(e, i, ''Admin'')'; RETURN r; END"),
vec![]
);
let mut c = pg_function(&[], &[], full_response_columns());
c.source = "mutation_ok(e, i, 'Admin')".to_string();
c.language = "c".to_string();
assert_eq!(check_mutation(&call, &[c]), vec![]);
}