mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-12 01:45:14 +02:00
tests/cli.rs (4,548 lines, 112 tests) becomes five area files — cli_cluster (24), cli_cluster_e2e (10, the spawned-binary lifecycle compositions), cli_data (49), cli_schema_config (16), cli_queries (13) — with the file-local helpers joining the existing tests/support harness. Verbatim moves + visibility bumps; 161 crate tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
535 lines
16 KiB
Rust
535 lines
16 KiB
Rust
//! Stored-query commands and alias resolution.
|
|
//! Moved verbatim from tests/cli.rs in the modularization.
|
|
|
|
|
|
use serde_json::Value;
|
|
use tempfile::tempdir;
|
|
|
|
mod support;
|
|
|
|
use support::*;
|
|
|
|
|
|
#[test]
|
|
fn query_check_alias_matches_lint_output() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Person {
|
|
name: String
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let lint_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let check_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("check")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
|
|
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
|
|
}
|
|
|
|
#[test]
|
|
fn read_alias_from_yaml_config_runs_with_kv_output() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("aliases.gq");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_query_file(
|
|
&query,
|
|
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n",
|
|
local_yaml_config(&graph)
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("owner")
|
|
.arg("Alice"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("row 1"));
|
|
assert!(stdout.contains("p.name: Alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("aliases.gq");
|
|
let data = temp.path().join("url-like.jsonl");
|
|
init_graph(&graph);
|
|
write_jsonl(
|
|
&data,
|
|
r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#,
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--mode")
|
|
.arg("overwrite")
|
|
.arg("--data")
|
|
.arg(&data)
|
|
.arg(&graph),
|
|
);
|
|
write_query_file(
|
|
&query,
|
|
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n",
|
|
graph.to_string_lossy()
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("owner")
|
|
.arg("https://example.com"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("row 1"));
|
|
assert!(stdout.contains("p.name: https://example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn change_alias_from_yaml_config_persists_changes() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("mutations.gq");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_query_file(
|
|
&query,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n",
|
|
local_yaml_config(&graph)
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("add_person")
|
|
.arg("Eve")
|
|
.arg("29")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["affected_nodes"], 1);
|
|
|
|
let verify = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve"}"#)
|
|
.arg("--json"),
|
|
);
|
|
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
|
|
assert_eq!(verify_payload["row_count"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_exits_zero_on_clean_registry() {
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&queries_test_config(
|
|
&graph.path().to_string_lossy(),
|
|
"find_person",
|
|
"find_person.gq",
|
|
),
|
|
);
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_exits_nonzero_on_type_broken_query() {
|
|
let graph = SystemGraph::loaded();
|
|
// `Widget` is not in the fixture schema.
|
|
graph.write_query(
|
|
"ghost.gq",
|
|
"query ghost() { match { $w: Widget } return { $w.name } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"),
|
|
);
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(
|
|
stdout.contains("ghost"),
|
|
"validation should name the broken query; stdout:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_prints_registered_query() {
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
// Exposed with an explicit tool name so the list shows the MCP suffix.
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&format!(
|
|
concat!(
|
|
"graphs:\n",
|
|
" local:\n",
|
|
" uri: '{}'\n",
|
|
" queries:\n",
|
|
" find_person:\n",
|
|
" file: ./find_person.gq\n",
|
|
" mcp: {{ expose: true, tool_name: lookup_person }}\n",
|
|
"cli:\n",
|
|
" graph: local\n",
|
|
"policy: {{}}\n",
|
|
),
|
|
graph.path().to_string_lossy().replace('\'', "''")
|
|
),
|
|
);
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
|
|
assert!(
|
|
stdout.contains("$name: String"),
|
|
"list should show typed params; stdout:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("[mcp: lookup_person]"),
|
|
"list should show the MCP tool name for exposed queries; stdout:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_requires_graph_selection_for_per_graph_only_registries() {
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&format!(
|
|
concat!(
|
|
"graphs:\n",
|
|
" local:\n",
|
|
" uri: '{}'\n",
|
|
" queries:\n",
|
|
" find_person:\n",
|
|
" file: ./find_person.gq\n",
|
|
"policy: {{}}\n",
|
|
),
|
|
graph.path().to_string_lossy().replace('\'', "''")
|
|
),
|
|
);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("local") && stderr.contains("--target local"),
|
|
"error must name the graph and give a concrete selection hint; stderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_without_graph_selection_lists_top_level_registry() {
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"top_find.gq",
|
|
"query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
concat!(
|
|
"queries:\n",
|
|
" top_find:\n",
|
|
" file: ./top_find.gq\n",
|
|
"policy: {}\n",
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(stdout.contains("top_find"), "stdout:\n{stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn queries_list_unknown_target_errors() {
|
|
// `queries list` opens no graph URI, so unknown-graph validation can't ride
|
|
// along on URI resolution the way it does for every other command. An
|
|
// unknown `--target` must still error (naming the graph) instead of
|
|
// silently falling back to the top-level registry and showing the wrong
|
|
// (or empty) catalog.
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&queries_test_config(
|
|
&graph.path().to_string_lossy(),
|
|
"find_person",
|
|
"find_person.gq",
|
|
),
|
|
);
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--target")
|
|
.arg("nonexistent")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("nonexistent"),
|
|
"error must name the unknown graph; stderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_commands_reject_named_graph_with_populated_top_level_block() {
|
|
// A named graph (here via `cli.graph`) uses its own `graphs.<name>` block,
|
|
// so a populated top-level `queries:` block would be silently ignored — a
|
|
// config the server REFUSES to boot. `queries validate`/`list` must reject
|
|
// it too (matching boot) instead of validating/listing the per-graph block
|
|
// and giving a false green.
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"find_person.gq",
|
|
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&format!(
|
|
concat!(
|
|
"graphs:\n",
|
|
" local:\n",
|
|
" uri: '{}'\n",
|
|
" queries:\n",
|
|
" find_person:\n",
|
|
" file: ./find_person.gq\n",
|
|
"cli:\n",
|
|
" graph: local\n",
|
|
"queries:\n", // populated top-level block: the coherence violation
|
|
" legacy:\n",
|
|
" file: ./legacy.gq\n",
|
|
"policy: {{}}\n",
|
|
),
|
|
graph.path().to_string_lossy().replace('\'', "''")
|
|
),
|
|
);
|
|
// Both resolve `local` from cli.graph (no positional URI), so both must
|
|
// error and name the graph + the ignored block — like server boot does.
|
|
for sub in ["validate", "list"] {
|
|
let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("local") && stderr.contains("queries"),
|
|
"`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
|
|
// Two exposed queries claiming one MCP tool name is a load-time
|
|
// collision — `queries validate` must fail (offline, before the engine
|
|
// opens) and name both queries plus the contested tool.
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"a.gq",
|
|
"query a() { match { $p: Person } return { $p.name } }",
|
|
);
|
|
graph.write_query(
|
|
"b.gq",
|
|
"query b() { match { $p: Person } return { $p.name } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
&format!(
|
|
concat!(
|
|
"graphs:\n",
|
|
" local:\n",
|
|
" uri: '{}'\n",
|
|
" queries:\n",
|
|
" a:\n",
|
|
" file: ./a.gq\n",
|
|
" mcp: {{ expose: true, tool_name: dup }}\n",
|
|
" b:\n",
|
|
" file: ./b.gq\n",
|
|
" mcp: {{ expose: true, tool_name: dup }}\n",
|
|
"cli:\n",
|
|
" graph: local\n",
|
|
"policy: {{}}\n",
|
|
),
|
|
graph.path().to_string_lossy().replace('\'', "''")
|
|
),
|
|
);
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"),
|
|
"duplicate tool name should be reported naming both queries; stderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queries_validate_positional_uri_ignores_default_graph() {
|
|
// A positional URI is anonymous → the schema AND the registry both come
|
|
// from top-level, even when `cli.graph` names a graph whose per-graph
|
|
// queries would fail. Pins that the URI and registry can't diverge.
|
|
let graph = SystemGraph::loaded();
|
|
graph.write_query(
|
|
"clean.gq",
|
|
"query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
|
);
|
|
// `Widget` is not in the fixture schema — the default graph's per-graph
|
|
// query would break validate if it were (wrongly) selected.
|
|
graph.write_query(
|
|
"broken.gq",
|
|
"query broken() { match { $w: Widget } return { $w.name } }",
|
|
);
|
|
let config = graph.write_config(
|
|
"omnigraph.yaml",
|
|
concat!(
|
|
"cli:\n graph: prod\n",
|
|
"graphs:\n",
|
|
" prod:\n",
|
|
" uri: /nonexistent-prod.omni\n",
|
|
" queries:\n",
|
|
" broken:\n",
|
|
" file: ./broken.gq\n",
|
|
"queries:\n",
|
|
" clean:\n",
|
|
" file: ./clean.gq\n",
|
|
"policy: {}\n",
|
|
),
|
|
);
|
|
// Positional URI = the real loaded graph; selection is anonymous, so the
|
|
// CLEAN top-level registry validates (not prod's broken one).
|
|
let output = output_success(
|
|
cli()
|
|
.arg("queries")
|
|
.arg("validate")
|
|
.arg(graph.path())
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
assert!(
|
|
stdout.contains("OK"),
|
|
"positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}"
|
|
);
|
|
}
|