mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
tests/server.rs (6,517 lines, 110 tests) becomes seven area files — auth_policy, data_routes, schema_routes, stored_queries, multi_graph, boot_settings, s3 — with shared helpers in tests/support/mod.rs. Verbatim moves + visibility bumps (pub on helpers, pub(super)->pub inside the matrix harness); cargo fix stripped the per-file unused imports. All 110 tests pass in their new homes (289 across the crate including lib and openapi). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
830 lines
28 KiB
Rust
830 lines
28 KiB
Rust
//! Schema read/apply routes: migrations over HTTP, drift, gating.
|
|
//! Moved verbatim from tests/server.rs in the modularization.
|
|
|
|
use std::fs;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode};
|
|
use lance::index::DatasetIndexExt;
|
|
use omnigraph::db::{Omnigraph, ReadTarget};
|
|
use omnigraph::loader::LoadMode;
|
|
use omnigraph_server::api::{
|
|
ChangeRequest, ErrorOutput, ReadRequest, SchemaApplyRequest, SchemaOutput,
|
|
};
|
|
use omnigraph_server::{AppState, build_app};
|
|
use serde_json::json;
|
|
|
|
|
|
mod support;
|
|
use support::*;
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_updates_graph_for_authorized_admin() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let schema = additive_schema_with_nickname();
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: schema,
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
let graph = graph_path(temp.path());
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
assert!(
|
|
reopened.catalog().node_types["Person"]
|
|
.properties
|
|
.contains_key("nickname")
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_rejects_stored_query_breakage_before_publish() {
|
|
let (temp, app) = app_with_stored_queries(
|
|
&[("find_person", FIND_PERSON_GQ, true)],
|
|
&[("act-ragnor", "admin-token")],
|
|
STORED_QUERY_SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: renamed_age_schema(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {payload}");
|
|
let message = payload["error"].as_str().unwrap_or_default();
|
|
assert!(
|
|
message.contains("find_person") && message.contains("schema check"),
|
|
"registry breakage should name the stored query; body: {payload}"
|
|
);
|
|
|
|
let reopened = Omnigraph::open(graph_path(temp.path()).to_str().unwrap())
|
|
.await
|
|
.unwrap();
|
|
let person = &reopened.catalog().node_types["Person"];
|
|
assert!(person.properties.contains_key("age"));
|
|
assert!(!person.properties.contains_key("years"));
|
|
|
|
let (invoke_status, invoke_body) = json_response(
|
|
&app,
|
|
invoke_request(
|
|
"find_person",
|
|
"admin-token",
|
|
json!({ "params": { "name": "Alice" } }),
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(invoke_status, StatusCode::OK, "body: {invoke_body}");
|
|
assert_eq!(invoke_body["row_count"], 1);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_noop_keeps_valid_stored_query_registry() {
|
|
let (_temp, app) = app_with_stored_queries(
|
|
&[("find_person", FIND_PERSON_GQ, true)],
|
|
&[("act-ragnor", "admin-token")],
|
|
STORED_QUERY_SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
assert_eq!(status, StatusCode::OK, "body: {payload}");
|
|
assert_eq!(payload["applied"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_requires_schema_apply_policy_permission() {
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: additive_schema_with_nickname(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
payload["code"],
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Forbidden).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_requires_bearer_token_when_policy_enabled() {
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: additive_schema_with_nickname(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
assert_eq!(
|
|
payload["code"],
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Unauthorized).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_can_rename_type() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: renamed_person_schema(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
let graph = graph_path(temp.path());
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
let snapshot = reopened
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap();
|
|
assert!(snapshot.entry("node:Human").is_some());
|
|
assert!(snapshot.entry("node:Person").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_can_rename_property() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: renamed_age_schema(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
let graph = graph_path(temp.path());
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
let person = &reopened.catalog().node_types["Person"];
|
|
assert!(person.properties.contains_key("years"));
|
|
assert!(!person.properties.contains_key("age"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_can_add_index() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let graph = graph_path(temp.path());
|
|
let before_index_count = {
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
dataset.load_indices().await.unwrap().len()
|
|
};
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: indexed_name_schema(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
let snapshot = reopened
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap();
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
let after_index_count = dataset.load_indices().await.unwrap().len();
|
|
assert!(after_index_count > before_index_count);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_rejects_unsupported_plan() {
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: unsupported_schema_change(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
payload["code"],
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::BadRequest).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_apply_route_rejects_when_non_main_branch_exists() {
|
|
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
|
let graph = graph_path(temp.path());
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
db.branch_create("feature").await.unwrap();
|
|
drop(db);
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
fs::write(&policy_path, SCHEMA_APPLY_POLICY_YAML).unwrap();
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
|
graph.to_string_lossy().to_string(),
|
|
vec![("act-ragnor".to_string(), "admin-token".to_string())],
|
|
Some(&policy_path),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let app = build_app(state);
|
|
|
|
let request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: additive_schema_with_nickname(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
assert_eq!(
|
|
payload["code"],
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Conflict).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_drift_returns_conflict_for_snapshot_read_and_change() {
|
|
let (temp, app) = app_for_loaded_graph().await;
|
|
let graph = graph_path(temp.path());
|
|
fs::write(graph.join("_schema.pg"), drifted_test_schema()).unwrap();
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/snapshot?branch=main")
|
|
.method(Method::GET)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let snapshot_error: ErrorOutput = serde_json::from_value(snapshot_body).unwrap();
|
|
assert_eq!(snapshot_status, StatusCode::CONFLICT);
|
|
assert_eq!(
|
|
snapshot_error.code,
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
);
|
|
assert!(
|
|
snapshot_error
|
|
.error
|
|
.contains("schema evolution is locked down in phase 1")
|
|
);
|
|
|
|
let read = ReadRequest {
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
query_name: Some("get_person".to_string()),
|
|
params: Some(json!({ "name": "Alice" })),
|
|
branch: Some("main".to_string()),
|
|
snapshot: None,
|
|
};
|
|
let (read_status, read_body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/read")
|
|
.method(Method::POST)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let read_error: ErrorOutput = serde_json::from_value(read_body).unwrap();
|
|
assert_eq!(read_status, StatusCode::CONFLICT);
|
|
assert_eq!(
|
|
read_error.code,
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
);
|
|
assert!(
|
|
read_error
|
|
.error
|
|
.contains("schema evolution is locked down in phase 1")
|
|
);
|
|
|
|
let change = ChangeRequest {
|
|
query: MUTATION_QUERIES.to_string(),
|
|
name: Some("insert_person".to_string()),
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
branch: Some("main".to_string()),
|
|
};
|
|
let (change_status, change_body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/change")
|
|
.method(Method::POST)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let change_error: ErrorOutput = serde_json::from_value(change_body).unwrap();
|
|
assert_eq!(change_status, StatusCode::CONFLICT);
|
|
assert_eq!(
|
|
change_error.code,
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
);
|
|
assert!(
|
|
change_error
|
|
.error
|
|
.contains("schema evolution is locked down in phase 1")
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_route_returns_current_source() {
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
|
let (status, body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/schema")
|
|
.method(Method::GET)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
let output: SchemaOutput = serde_json::from_value(body).unwrap();
|
|
assert!(output.schema_source.contains("node Person"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_route_requires_bearer_token_when_auth_configured() {
|
|
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
|
|
|
let (missing_status, missing_body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/schema")
|
|
.method(Method::GET)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap();
|
|
assert_eq!(missing_status, StatusCode::UNAUTHORIZED);
|
|
assert_eq!(
|
|
missing_error.code,
|
|
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
|
);
|
|
|
|
let (ok_status, ok_body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/schema")
|
|
.method(Method::GET)
|
|
.header("authorization", "Bearer demo-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(ok_status, StatusCode::OK);
|
|
let output: SchemaOutput = serde_json::from_value(ok_body).unwrap();
|
|
assert!(!output.schema_source.is_empty());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_route_denied_when_actor_lacks_read_permission() {
|
|
let temp = init_loaded_graph().await;
|
|
let graph = graph_path(temp.path());
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
// Policy grants branch_create only — no read action for act-bruno.
|
|
fs::write(&policy_path, INGEST_CREATE_ONLY_POLICY_YAML).unwrap();
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
|
graph.to_string_lossy().to_string(),
|
|
vec![("act-bruno".to_string(), "team-token".to_string())],
|
|
Some(&policy_path),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let app = build_app(state);
|
|
|
|
let (status, body) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.uri("/schema")
|
|
.method(Method::GET)
|
|
.header("authorization", "Bearer team-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
error.code,
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_soft_drops_property_via_http() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
// Load a row that has the column we're about to drop.
|
|
let graph = graph_path(temp.path());
|
|
{
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
db.load(
|
|
"main",
|
|
r#"{"type":"Person","data":{"name":"PreDrop","age":42}}"#,
|
|
LoadMode::Append,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let pre_version = manifest_dataset_version(&graph).await;
|
|
|
|
let (status, payload) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: schema_without_age(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
// Catalog reflects the drop: `age` is gone from the live schema.
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
assert!(
|
|
!reopened.catalog().node_types["Person"]
|
|
.properties
|
|
.contains_key("age"),
|
|
"catalog should not contain `age` after drop"
|
|
);
|
|
|
|
// Soft drop preserves the prior version — `age` is still readable
|
|
// via time travel to the pre-drop manifest version. Mirrors the
|
|
// SDK-side assertion in `apply_schema_drops_a_nullable_property_softly_preserves_prior_version`.
|
|
let pre_drop_snapshot = reopened.snapshot_at_version(pre_version).await.unwrap();
|
|
let pre_drop_ds = pre_drop_snapshot.open("node:Person").await.unwrap();
|
|
let pre_drop_fields = pre_drop_ds
|
|
.schema()
|
|
.fields
|
|
.iter()
|
|
.map(|f| f.name.clone())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
pre_drop_fields.iter().any(|f| f == "age"),
|
|
"soft drop should leave the pre-drop dataset's `age` column \
|
|
time-travel-reachable; got fields {pre_drop_fields:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_soft_drops_node_type_via_http() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let graph = graph_path(temp.path());
|
|
|
|
let (status, payload) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: schema_without_company(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
assert!(
|
|
!reopened.catalog().node_types.contains_key("Company"),
|
|
"catalog should not contain `Company` after drop"
|
|
);
|
|
assert!(
|
|
!reopened.catalog().edge_types.contains_key("WorksAt"),
|
|
"catalog should not contain `WorksAt` after cascade"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_hard_drops_property_with_allow_data_loss() {
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let graph = graph_path(temp.path());
|
|
{
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
db.load(
|
|
"main",
|
|
r#"{"type":"Person","data":{"name":"PreDropHard","age":50}}"#,
|
|
LoadMode::Append,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// Apply with allow_data_loss=true → Hard mode promotion.
|
|
let (status, payload) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: schema_without_age(),
|
|
allow_data_loss: true,
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
// Catalog reflects the drop.
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
assert!(
|
|
!reopened.catalog().node_types["Person"]
|
|
.properties
|
|
.contains_key("age"),
|
|
"catalog should not contain `age` after Hard drop"
|
|
);
|
|
// Plan steps should show DropMode::Hard for property drops.
|
|
let steps = payload["steps"].as_array().expect("steps array");
|
|
let drop_step = steps
|
|
.iter()
|
|
.find(|s| s["kind"] == "drop_property")
|
|
.expect("plan should include drop_property step");
|
|
let mode = &drop_step["mode"];
|
|
assert_eq!(
|
|
mode, "hard",
|
|
"expected hard mode under allow_data_loss=true"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_keeps_drops_soft_without_flag() {
|
|
// Symmetric to the Hard test: same schema change, but no
|
|
// allow_data_loss flag → drops stay Soft (prior column data
|
|
// remains time-travel-reachable). Pins the default semantics
|
|
// against accidental Hard promotion.
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let graph = graph_path(temp.path());
|
|
|
|
let (status, payload) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: schema_without_age(),
|
|
allow_data_loss: false,
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let steps = payload["steps"].as_array().expect("steps array");
|
|
let drop_step = steps
|
|
.iter()
|
|
.find(|s| s["kind"] == "drop_property")
|
|
.expect("plan should include drop_property step");
|
|
let mode = &drop_step["mode"];
|
|
assert_eq!(mode, "soft", "expected soft mode without allow_data_loss");
|
|
let _ = graph;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn schema_apply_route_additive_property_preserves_existing_rows() {
|
|
// SDK suite covers rename and drop data preservation. Additive
|
|
// AddProperty wasn't pinned with a row-count check anywhere.
|
|
// Load N rows, apply schema adding nullable property, verify
|
|
// every row is still readable and the new column is null.
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
&[("act-ragnor", "admin-token")],
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
)
|
|
.await;
|
|
let graph = graph_path(temp.path());
|
|
|
|
// Standard fixture data: 4 Persons + 1 Company. Load it.
|
|
let pre_count = {
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
db.load(
|
|
"main",
|
|
&fs::read_to_string(fixture("test.jsonl")).unwrap(),
|
|
LoadMode::Append,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let snap = db
|
|
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap();
|
|
snap.entry("node:Person").expect("Person").row_count
|
|
};
|
|
assert!(pre_count > 0, "fixture should have loaded Person rows");
|
|
|
|
let (status, payload) = json_response(
|
|
&app,
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/schema/apply")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", "Bearer admin-token")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
schema_source: additive_schema_with_nickname(),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
// Row count preserved.
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
let snap = db
|
|
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap();
|
|
let post_count = snap.entry("node:Person").expect("Person").row_count;
|
|
assert_eq!(
|
|
post_count, pre_count,
|
|
"AddProperty should preserve row count",
|
|
);
|
|
}
|