use crate::error::Result;
use crate::model::CURRENT_SCHEMA_VERSION;
use serde_json::Value;
pub fn migrate(mut value: Value) -> Result<Value> {
let mut version = value
.get("schema_version")
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
while version < CURRENT_SCHEMA_VERSION {
version += 1;
}
if let Value::Object(map) = &mut value {
map.insert(
"schema_version".to_string(),
Value::from(CURRENT_SCHEMA_VERSION),
);
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::WorkflowGraph;
use serde_json::json;
#[test]
fn versionless_json_deserializes_with_defaults() {
let raw = json!({
"name": "legacy",
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Trigger" }
],
"edges": []
});
let graph: WorkflowGraph = serde_json::from_value(raw).expect("deserialize");
assert_eq!(graph.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(graph.nodes[0].type_version, 1);
}
#[test]
fn migrate_stamps_current_schema_version() {
let raw = json!({
"name": "legacy",
"nodes": [],
"edges": []
});
let upgraded = migrate(raw).expect("migrate");
assert_eq!(
upgraded.get("schema_version").and_then(|v| v.as_u64()),
Some(u64::from(CURRENT_SCHEMA_VERSION))
);
let graph: WorkflowGraph = serde_json::from_value(upgraded).expect("deserialize");
assert_eq!(graph.schema_version, CURRENT_SCHEMA_VERSION);
}
#[test]
fn unknown_node_kind_errors_not_panics() {
let raw = json!({
"schema_version": 1,
"nodes": [
{ "id": "x", "kind": "bogus", "name": "Mystery" }
],
"edges": []
});
let result: std::result::Result<WorkflowGraph, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"unknown node kind must fail to deserialize"
);
}
#[test]
fn versionless_graph_gains_schema_and_node_type_version_defaults() {
let raw = json!({
"name": "legacy",
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Trigger" },
{ "id": "a", "kind": "agent", "name": "Agent" }
],
"edges": []
});
let upgraded = migrate(raw).expect("migrate");
assert_eq!(upgraded["schema_version"], json!(CURRENT_SCHEMA_VERSION));
let graph: WorkflowGraph = serde_json::from_value(upgraded).expect("deserialize");
assert_eq!(graph.schema_version, CURRENT_SCHEMA_VERSION);
assert!(graph.nodes.iter().all(|n| n.type_version == 1));
}
#[test]
fn already_current_document_passes_through_unchanged() {
let current = json!({
"schema_version": CURRENT_SCHEMA_VERSION,
"name": "ok",
"nodes": [
{ "id": "t", "kind": "trigger", "type_version": 1, "name": "Trigger" }
],
"edges": []
});
let out = migrate(current.clone()).expect("migrate");
assert_eq!(out, current);
}
#[test]
fn migrate_is_idempotent() {
let raw = json!({
"name": "legacy",
"nodes": [ { "id": "t", "kind": "trigger", "name": "Trigger" } ],
"edges": []
});
let once = migrate(raw).expect("first migrate");
let twice = migrate(once.clone()).expect("second migrate");
assert_eq!(once, twice);
}
#[test]
fn explicit_zero_schema_version_is_upgraded() {
let raw = json!({
"schema_version": 0,
"name": "legacy",
"nodes": [],
"edges": []
});
let upgraded = migrate(raw).expect("migrate");
assert_eq!(upgraded["schema_version"], json!(CURRENT_SCHEMA_VERSION));
}
#[test]
fn non_object_input_is_returned_unchanged() {
assert_eq!(migrate(json!(42)).expect("number"), json!(42));
assert_eq!(migrate(json!("text")).expect("string"), json!("text"));
assert_eq!(migrate(json!([1, 2, 3])).expect("array"), json!([1, 2, 3]));
assert_eq!(migrate(json!(null)).expect("null"), json!(null));
}
#[test]
fn full_graph_round_trips_through_migrate() {
let raw = json!({
"schema_version": 1,
"id": "wf_1",
"name": "demo",
"nodes": [
{ "id": "t", "kind": "trigger", "type_version": 1, "name": "Trigger",
"config": { "trigger_kind": "manual" } },
{ "id": "a", "kind": "agent", "type_version": 1, "name": "Agent" }
],
"edges": [
{ "from_node": "t", "from_port": "main", "to_node": "a", "to_port": "main" }
]
});
let upgraded = migrate(raw).expect("migrate");
let graph: WorkflowGraph =
serde_json::from_value(upgraded).expect("deserialize migrated graph");
let reserialized = serde_json::to_value(&graph).expect("serialize");
let remigrated = migrate(reserialized).expect("re-migrate");
let graph_again: WorkflowGraph =
serde_json::from_value(remigrated).expect("deserialize again");
assert_eq!(graph, graph_again);
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
}
use proptest::prelude::*;
fn arb_json() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::from),
any::<i64>().prop_map(Value::from),
"[A-Za-z0-9_]{0,8}".prop_map(Value::from),
];
leaf.prop_recursive(3, 16, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..4).prop_map(Value::from),
prop::collection::hash_map("[A-Za-z_][A-Za-z0-9_]{0,5}", inner, 0..4)
.prop_map(|m| Value::from(m.into_iter().collect::<serde_json::Map<_, _>>())),
]
})
}
fn arb_json_with_schema_version() -> impl Strategy<Value = Value> {
(arb_json(), prop::option::of(arb_json())).prop_map(|(base, maybe_version)| {
let mut map = match base {
Value::Object(m) => m,
other => {
let mut m = serde_json::Map::new();
m.insert("inner".to_string(), other);
m
}
};
if let Some(version) = maybe_version {
map.insert("schema_version".to_string(), version);
}
Value::Object(map)
})
}
proptest! {
#[test]
fn prop_migrate_never_panics(value in arb_json()) {
let _ = migrate(value);
}
#[test]
fn prop_migrate_never_panics_with_schema_version(value in arb_json_with_schema_version()) {
let _ = migrate(value);
}
#[test]
fn prop_migrate_is_idempotent(value in arb_json_with_schema_version()) {
if let Ok(once) = migrate(value) {
let twice = migrate(once.clone()).expect("second migrate cannot fail");
prop_assert_eq!(once, twice);
}
}
#[test]
fn prop_migrate_object_is_stamped(value in arb_json_with_schema_version()) {
if let Ok(out) = migrate(value)
&& let Value::Object(map) = &out
{
prop_assert_eq!(
map.get("schema_version").and_then(Value::as_u64),
Some(u64::from(CURRENT_SCHEMA_VERSION))
);
}
}
}
}