Preserve editable routes across source projects and REST
This commit is contained in:
@@ -140,6 +140,280 @@ fn graph() -> Project {
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_route_transactions_source_and_json_share_defaults_etags_and_atomic_rejection() {
|
||||
let model = oa_core::language::parse(
|
||||
r#"arcadia 3; project routing "Routing" {
|
||||
oa_activity a "Report" {}
|
||||
oa_activity b "Assess" {}
|
||||
interaction signal "Signal" { source @a; target @b; }
|
||||
diagram architecture "Architecture" {}
|
||||
diagram review "Review" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let find = |alias: &str| {
|
||||
model
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let diagram = find("architecture");
|
||||
let other = find("review");
|
||||
let edge = find("signal");
|
||||
let element = find("a");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, etag) = server.import(&model).await;
|
||||
let url = server.url(&format!("/projects/{id}/transactions"));
|
||||
// The second route intentionally omits both optional fields, exercising
|
||||
// the public JSON defaults rather than serializing a pre-hydrated struct.
|
||||
let commands = json!([
|
||||
{"SetEdgeRoute":{"diagram":diagram,"route":{
|
||||
"edge":edge,"waypoints":[{"x":420.0,"y":260.0},{"x":690.0,"y":260.0}],
|
||||
"label":{"fraction":0.55,"offset":{"x":0.0,"y":-16.0}}
|
||||
}}},
|
||||
{"SetEdgeRoute":{"diagram":other,"route":{"edge":edge}}}
|
||||
]);
|
||||
let typed: Vec<Command> = serde_json::from_value(commands.clone()).unwrap();
|
||||
let expected = oa_core::execute_batch(&model, &typed).unwrap();
|
||||
let preview = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":commands,"dry_run":true}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preview.status(), StatusCode::OK);
|
||||
let preview: Value = preview.json().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(server.get(id).await.model, model);
|
||||
|
||||
let commit = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(commit.status(), StatusCode::OK);
|
||||
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let stored = server.get(id).await;
|
||||
assert_eq!(stored.revision, 2);
|
||||
assert_eq!(stored.model, expected);
|
||||
assert!(stored.model.diagrams[&other].routes[0].waypoints.is_empty());
|
||||
assert!(stored.model.diagrams[&other].routes[0].label.is_none());
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{id}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.contains("edge @signal;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), expected);
|
||||
let imported = server
|
||||
.client
|
||||
.post(server.url("/projects/import"))
|
||||
.header("content-type", "text/plain")
|
||||
.body(source.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imported.status(), StatusCode::CREATED);
|
||||
let imported: Value = imported.json().await.unwrap();
|
||||
assert_eq!(
|
||||
server
|
||||
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
|
||||
.await
|
||||
.model,
|
||||
expected
|
||||
);
|
||||
|
||||
let reset = json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}});
|
||||
for stale_command in [commands[0].clone(), reset.clone()] {
|
||||
let response = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":[stale_command]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
|
||||
}
|
||||
let no_op = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_op.status(), StatusCode::OK);
|
||||
assert_eq!(no_op.headers()["etag"], new_etag);
|
||||
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
|
||||
|
||||
let invalid_routes = [
|
||||
(json!({"edge":element}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
json!({"edge":edge,"waypoints":vec![json!({"x":0,"y":0});129]}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"waypoints":[{"x":1000001,"y":0}]}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"label":{"fraction":2,"offset":{"x":0,"y":0}}}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"label":{"fraction":0.5}}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(json!({"edge":edge,"unknown":true}), StatusCode::BAD_REQUEST),
|
||||
];
|
||||
for (route, status) in invalid_routes {
|
||||
let response = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":[
|
||||
Command::Rename(element, "Must roll back".into()),
|
||||
{"SetEdgeRoute":{"diagram":diagram,"route":route}}
|
||||
]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
status,
|
||||
"{route}: {}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
}
|
||||
let mut duplicated = expected.clone();
|
||||
let route = duplicated.diagrams[&diagram].routes[0].clone();
|
||||
duplicated
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route);
|
||||
let rejected = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/model")))
|
||||
.header("if-match", &new_etag)
|
||||
.json(&duplicated)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(server.get(id).await.model, stored.model);
|
||||
assert_eq!(server.get(id).await.revision, stored.revision);
|
||||
|
||||
// The source write path hydrates the same metadata, and an equal source
|
||||
// application must not create a revision or erase an explicit empty route.
|
||||
let source_no_op = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source")))
|
||||
.header("if-match", &new_etag)
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source_no_op.status(), StatusCode::OK);
|
||||
assert_eq!(source_no_op.headers()["etag"], new_etag);
|
||||
|
||||
let removed = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":[reset]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed.status(), StatusCode::OK);
|
||||
let removed_etag = removed.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let remaining = server.get(id).await;
|
||||
assert!(remaining.model.diagrams[&diagram].routes.is_empty());
|
||||
assert_eq!(remaining.model.diagrams[&other], expected.diagrams[&other]);
|
||||
let removed_again = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &removed_etag)
|
||||
.json(&json!({"commands":[reset]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed_again.status(), StatusCode::OK);
|
||||
assert_eq!(removed_again.headers()["etag"], removed_etag);
|
||||
assert_eq!(
|
||||
removed_again.json::<Value>().await.unwrap()["changed"],
|
||||
false
|
||||
);
|
||||
server.stop().await;
|
||||
let reopened = TestServer::start(directory.path(), None).await;
|
||||
assert_eq!(reopened.get(id).await.model, remaining.model);
|
||||
assert_eq!(reopened.get(id).await.revision, remaining.revision);
|
||||
reopened.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_openapi_describes_the_serde_command_shapes_and_nested_defaults() {
|
||||
let spec: Value = serde_json::from_str(include_str!("../../../docs/openapi.json")).unwrap();
|
||||
let schemas = &spec["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["Diagram"]["properties"]["routes"]["default"],
|
||||
json!([])
|
||||
);
|
||||
assert!(
|
||||
!schemas["Diagram"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("routes"))
|
||||
);
|
||||
assert_eq!(schemas["EdgeRoute"]["required"], json!(["edge"]));
|
||||
assert_eq!(
|
||||
schemas["EdgeRoute"]["properties"]["waypoints"]["maxItems"],
|
||||
oa_core::MAX_ROUTE_POINTS
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["EdgeRoute"]["properties"]["waypoints"]["default"],
|
||||
json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["EdgeLabel"]["required"],
|
||||
json!(["fraction", "offset"])
|
||||
);
|
||||
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["minimum"], 0);
|
||||
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["maximum"], 1);
|
||||
let commands = schemas["Command"]["oneOf"].as_array().unwrap();
|
||||
for (name, fields) in [
|
||||
("SetEdgeRoute", json!(["diagram", "route"])),
|
||||
("RemoveEdgeRoute", json!(["diagram", "edge"])),
|
||||
] {
|
||||
let schema = commands
|
||||
.iter()
|
||||
.find(|schema| schema["properties"].get(name).is_some())
|
||||
.unwrap();
|
||||
assert_eq!(schema["required"], json!([name]));
|
||||
assert_eq!(schema["properties"][name]["required"], fields);
|
||||
assert_eq!(schema["properties"][name]["additionalProperties"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user