#![allow(clippy::unwrap_used, clippy::panic)]
use std::{path::PathBuf, process::Command};
use tempfile::TempDir;
fn fixture_module_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/functions")
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_fraiseql-cli"))
}
fn write_schema(dir: &TempDir) -> PathBuf {
write_schema_declaring(
dir,
&serde_json::json!([{
"name": "notify_approved",
"trigger": "after:mutation:Order:update",
"runtime": "Deno",
"when": [{"field": "status", "changed_to": "approved"}]
}]),
)
}
fn write_schema_declaring(dir: &TempDir, functions: &serde_json::Value) -> PathBuf {
let module_dir = fixture_module_dir();
let types = serde_json::json!({
"version": "2.0.0",
"types": [{
"name": "Order",
"sql_source": "v_order",
"is_input": false,
"fields": [
{"name": "id", "type": "ID", "nullable": false},
{"name": "status", "type": "String", "nullable": false}
]
}],
"mutations": [{
"name": "updateOrder",
"return_type": "Order",
"sql_source": "fn_update_order",
"operation": "update",
"invalidates_views": ["v_order"],
"arguments": []
}],
"functions": functions
});
let types_path = dir.path().join("types.json");
std::fs::write(&types_path, serde_json::to_string_pretty(&types).unwrap()).unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
format!(
"[schema]\nname = \"invoke\"\nversion = \"1.0.0\"\n\
database_target = \"postgresql\"\n\n\
[functions]\nmodule_dir = \"{}\"\n",
module_dir.display()
),
)
.unwrap();
let path = dir.path().join("schema.compiled.json");
let output = cli()
.arg("compile")
.arg(&toml_path)
.arg("--types")
.arg(&types_path)
.arg("--output")
.arg(&path)
.output()
.unwrap();
assert!(
output.status.success(),
"the fixture project must compile:\n{}",
String::from_utf8_lossy(&output.stderr)
);
path
}
fn write_json(dir: &TempDir, name: &str, value: &serde_json::Value) -> PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, serde_json::to_string(value).unwrap()).unwrap();
path
}
#[test]
fn matching_payload_runs_and_reports_result_and_host_ops() {
let dir = TempDir::new().unwrap();
let schema = write_schema(&dir);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({
"event_kind": "update",
"old": { "id": "o-1", "status": "pending" },
"new": { "id": "o-1", "status": "approved" }
}),
);
let mock_query = write_json(
&dir,
"query.json",
&serde_json::json!([ { "response": { "data": { "markNotified": { "id": "o-1" } } } } ]),
);
let out = cli()
.args(["functions", "invoke", "notify_approved"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.arg("--mock-query")
.arg(&mock_query)
.args(["--idempotency-token", "tok-123"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 0, "a matching payload runs (exit 0); stdout:\n{stdout}");
assert!(stdout.contains("notified"), "the guest's result is printed:\n{stdout}");
assert!(stdout.contains("tok-123"), "the injected token is visible:\n{stdout}");
assert!(stdout.contains("host ops"), "host-op calls are reported:\n{stdout}");
assert!(stdout.contains("query"), "the guest's query op is recorded:\n{stdout}");
}
#[test]
fn non_matching_predicate_exits_predicate_no_match() {
let dir = TempDir::new().unwrap();
let schema = write_schema(&dir);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({
"event_kind": "update",
"old": { "id": "o-2", "status": "approved" },
"new": { "id": "o-2", "status": "approved" }
}),
);
let out = cli()
.args(["functions", "invoke", "notify_approved"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.arg("--explain")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 3, "a non-matching predicate exits 3 (no isolate); stdout:\n{stdout}");
assert!(stdout.contains("NO MATCH"), "--explain shows why it did not fire:\n{stdout}");
}
#[test]
fn mock_query_miss_fails_loud_and_the_guest_errors() {
let dir = TempDir::new().unwrap();
let schema = write_schema(&dir);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({
"event_kind": "update",
"old": { "id": "o-3", "status": "pending" },
"new": { "id": "o-3", "status": "approved" }
}),
);
let mock_query = write_json(
&dir,
"query.json",
&serde_json::json!([ { "query_contains": "somethingElse", "response": {} } ]),
);
let out = cli()
.args(["functions", "invoke", "notify_approved"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.arg("--mock-query")
.arg(&mock_query)
.output()
.unwrap();
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 4, "an unmatched mock fails the op loud → guest error (exit 4)");
}
#[test]
fn a_declared_timeout_cuts_the_guest_off() {
let dir = TempDir::new().unwrap();
let schema = write_schema_declaring(
&dir,
&serde_json::json!([{
"name": "spin_forever",
"trigger": "after:mutation:Order:update",
"runtime": "Deno",
"timeout_ms": 100
}]),
);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({ "event_kind": "update", "old": {}, "new": { "id": "o-9" } }),
);
let started = std::time::Instant::now();
let out = cli()
.args(["functions", "invoke", "spin_forever"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.output()
.unwrap();
let elapsed = started.elapsed();
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 4, "a guest cut off by its timeout is a guest error (exit 4)");
assert!(
elapsed < std::time::Duration::from_secs(3),
"the guest ran for {elapsed:?}, so its 100 ms timeout was not applied"
);
}
#[test]
fn unknown_function_is_a_config_error() {
let dir = TempDir::new().unwrap();
let schema = write_schema(&dir);
let payload = write_json(&dir, "payload.json", &serde_json::json!({ "id": "x" }));
let out = cli()
.args(["functions", "invoke", "does_not_exist"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.output()
.unwrap();
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 1, "an unknown function name is a config error (exit 1)");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("does_not_exist") || stderr.to_lowercase().contains("no function"),
"the error names the missing function:\n{stderr}"
);
}
fn write_before_mutation_schema(dir: &TempDir) -> PathBuf {
let module_dir = fixture_module_dir();
let types = serde_json::json!({
"version": "2.0.0",
"types": [{
"name": "Order",
"sql_source": "v_order",
"is_input": false,
"fields": [
{"name": "id", "type": "ID", "nullable": false},
{"name": "amount", "type": "Int", "nullable": false}
]
}],
"mutations": [{
"name": "placeOrder",
"return_type": "Order",
"sql_source": "fn_place_order",
"operation": "insert",
"invalidates_views": ["v_order"],
"arguments": []
}],
"functions": [{
"name": "credit_limit",
"trigger": "before:mutation:placeOrder",
"runtime": "Deno"
}]
});
let types_path = dir.path().join("types.json");
std::fs::write(&types_path, serde_json::to_string_pretty(&types).unwrap()).unwrap();
let toml_path = dir.path().join("fraiseql.toml");
std::fs::write(
&toml_path,
format!(
"[schema]\nname = \"invoke\"\nversion = \"1.0.0\"\n\
database_target = \"postgresql\"\n\n\
[functions]\nmodule_dir = \"{}\"\n",
module_dir.display()
),
)
.unwrap();
let path = dir.path().join("schema.compiled.json");
let output = cli()
.arg("compile")
.arg(&toml_path)
.arg("--types")
.arg(&types_path)
.arg("--output")
.arg(&path)
.output()
.unwrap();
assert!(
output.status.success(),
"the fixture project must compile:\n{}",
String::from_utf8_lossy(&output.stderr)
);
path
}
fn credit_mock(dir: &TempDir, limit: i64, outstanding: i64) -> PathBuf {
write_json(
dir,
"query.json",
&serde_json::json!([{
"query_contains": "customer",
"response": { "data": { "customer": {
"creditLimit": limit, "outstanding": outstanding,
} } }
}]),
)
}
#[test]
fn a_data_dependent_rule_aborts_when_the_read_says_so() {
let dir = TempDir::new().unwrap();
let schema = write_before_mutation_schema(&dir);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({ "input": { "customer_id": "c-1", "amount": 900 } }),
);
let mock_query = credit_mock(&dir, 1000, 400);
let out = cli()
.args(["functions", "invoke", "credit_limit"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.arg("--mock-query")
.arg(&mock_query)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "the guest ran; stdout:\n{stdout}");
assert!(
stdout.contains("decision: ABORT `placeOrder`"),
"the harness must report the decision the chain would reach:\n{stdout}"
);
assert!(
stdout.contains("exceeds the remaining credit of 600"),
"the rule's own message must be shown:\n{stdout}"
);
}
#[test]
fn the_same_rule_proceeds_and_rewrites_when_the_read_allows_it() {
let dir = TempDir::new().unwrap();
let schema = write_before_mutation_schema(&dir);
let payload = write_json(
&dir,
"payload.json",
&serde_json::json!({ "input": { "customer_id": "c-1", "amount": 100 } }),
);
let mock_query = credit_mock(&dir, 1000, 400);
let out = cli()
.args(["functions", "invoke", "credit_limit"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.arg("--mock-query")
.arg(&mock_query)
.arg("--json")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "the guest ran; stdout:\n{stdout}");
let decision: serde_json::Value = stdout
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|value| value.get("decision").is_some())
.expect("--json must emit the decision object");
assert_eq!(decision["decision"], "proceed");
assert_eq!(decision["rewritten"], true, "the hook stamped the input: {decision}");
assert_eq!(decision["arguments_or_reason"]["credit_checked"], true);
}
#[test]
fn a_non_object_before_mutation_fixture_is_a_config_error() {
let dir = TempDir::new().unwrap();
let schema = write_before_mutation_schema(&dir);
let payload = write_json(&dir, "payload.json", &serde_json::json!([1, 2, 3]));
let out = cli()
.args(["functions", "invoke", "credit_limit"])
.arg("--payload")
.arg(&payload)
.arg("--schema")
.arg(&schema)
.output()
.unwrap();
assert_eq!(out.status.code().unwrap_or(-1), 1, "a bad fixture is a config error");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("arguments object") && stderr.contains("an array"),
"the error must say what was expected and what was found:\n{stderr}"
);
}