//! Transport evidence for the published R6 contract, not a second model fixture. use super::*; use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle}; use oa_core::{DataTypeDefinition, DataValue, SharedBody, TransmissionProtocol}; use std::collections::BTreeMap; fn documented_data() -> Project { let contract = include_str!("../../../../docs/language.rst"); let example = contract .split("This complete data contract example is also exercised through the REST API:") .nth(1) .unwrap() .split(".. code-block:: text") .nth(1) .unwrap(); let source = example .lines() .skip_while(|line| line.trim().is_empty()) .take_while(|line| line.starts_with(" ") || line.is_empty()) .map(|line| line.strip_prefix(" ").unwrap_or(line)) .collect::>() .join("\n"); let model = oa_core::language::parse(&source).unwrap(); oa_core::validate_candidate(&model).unwrap(); model } fn id(model: &Project, alias: &str) -> Id { model .identities() .into_iter() .find(|(_, a)| *a == alias) .unwrap() .0 } fn assert_pair(stored: &StoredProject) { let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap(); assert_eq!(compiled.decoded.project, stored.model); assert_eq!(compiled.bundle, stored.source_project); } async fn commands( server: &TestServer, current: &StoredProject, commands: Vec, ) -> reqwest::Response { server .client .post(server.url(&format!("/projects/{}/transactions", current.resource_id))) .header("if-match", current.etag()) .json(&json!({"commands":commands})) .send() .await .unwrap() } #[tokio::test] async fn published_data_contract_queries_atomic_edits_identity_and_restart() { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let model = documented_data(); let (resource, _) = server.import(&model).await; let before = server.get(resource).await; assert_eq!(before.model, model); assert_pair(&before); for (kind, count) in [("InterfaceItemAllocation", 2), ("DataAssociation", 1)] { let result: Value = server .client .get(server.url(&format!("/projects/{resource}/relations?kind={kind}"))) .send() .await .unwrap() .error_for_status() .unwrap() .json() .await .unwrap(); assert_eq!(result["items"].as_array().unwrap().len(), count); } let voltage = id(&model, "voltage"); let mut invalid = model.elements[&voltage].clone(); let Some(SharedBody::DataType { definition: DataTypeDefinition::Real { maximum, .. }, .. }) = invalid.shared_mut() else { panic!() }; *maximum = Some(12.0); // Existing default24V is outside this bound. let filename = directory.path().join(format!("{resource}.json")); let disk_before = std::fs::read(&filename).unwrap(); assert_eq!( commands( &server, &before, vec![Command::EditElement(voltage, invalid.clone())] ) .await .status(), StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!(server.get(resource).await.etag(), before.etag()); assert_eq!(std::fs::read(&filename).unwrap(), disk_before); let Some(SharedBody::DataType { default, .. }) = invalid.shared_mut() else { panic!() }; *default = Some(DataValue::Real(12.0)); let nav = id(&model, "nav_operation"); let other = id(&model, "monitor_operation"); let mut allocation = model.relations[&nav].clone(); let oa_core::RelationBody::InterfaceItemAllocation { transmission, .. } = &mut allocation.body else { panic!() }; *transmission = TransmissionProtocol::Unicast; let artifact = oa_core::ArtifactLink { id: Id::new_v4(), alias: "navigation_evidence".into(), name: "Navigation CAD evidence".into(), description: "Independent allocation evidence".into(), owner: nav, relation: oa_core::ArtifactRelation::Verifies, uri: "cad://navigation/connector".into(), revision: Some("review-17".into()), fragment: Some("pin-3".into()), }; let response = commands( &server, &before, vec![ Command::EditElement(voltage, invalid), Command::EditRelation(nav, allocation.clone()), Command::CreateArtifact(artifact.clone()), ], ) .await; assert_eq!( response.status(), StatusCode::OK, "{}", response.text().await.unwrap() ); let changed = server.get(resource).await; assert_eq!(changed.revision, before.revision + 1); assert_eq!(changed.model.relations[&other], model.relations[&other]); assert_eq!(changed.model.relations[&nav], allocation); assert_eq!(changed.model.artifact_links[&artifact.id], artifact); assert_pair(&changed); assert_eq!( commands(&server, &before, vec![Command::Rename(nav, "Stale".into())]) .await .status(), StatusCode::PRECONDITION_FAILED ); let renamed = Id::new_v4(); assert_eq!( commands(&server, &changed, vec![Command::Reidentify(nav, renamed)]) .await .status(), StatusCode::OK ); let reidentified = server.get(resource).await; assert_eq!( reidentified.model.artifact_links[&artifact.id].owner, renamed ); // This allocation is not enforcing a live boundary, so removal is valid. assert_eq!( commands(&server, &reidentified, vec![Command::Delete(renamed)]) .await .status(), StatusCode::OK ); let removed = server.get(resource).await; assert!(removed.model.elements.contains_key(&id(&model, "navigate"))); assert_eq!(removed.model.relations[&other], model.relations[&other]); assert!(!removed.model.artifact_links.contains_key(&artifact.id)); assert_pair(&removed); let source = server .client .get(server.url(&format!("/projects/{resource}/source"))) .send() .await .unwrap() .text() .await .unwrap(); assert!(source.starts_with("arcadia 5;")); assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model); server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(resource).await; assert_eq!(reopened.model, removed.model); assert_eq!(reopened.etag(), removed.etag()); assert_pair(&reopened); server.stop().await; } #[tokio::test] async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_files() { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let old = SourceBundle { entry: "legacy.arcadia".into(), files: BTreeMap::from([ ("legacy.arcadia".into(), "// Entry rationale\narcadia 3; project old_data \"Old data\" { include \"dictionary.arcadia\"; }\n".into()), ("dictionary.arcadia".into(), "// Event rationale\nsa_exchange_item notification \"Notification\" { kind Event; }\n// Interface rationale\nsa_interface contract \"Contract\" { items [@notification]; }\n".into()) ]) }; let response = server .client .post(server.url("/projects/import-source-project")) .json(&old) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); let summary: Value = response.json().await.unwrap(); let resource = summary["resource_id"].as_str().unwrap().parse().unwrap(); let stored = server.get(resource).await; let report = stored.migration.as_ref().unwrap(); assert_eq!((report.from_schema, report.to_schema), (3, 5)); assert_eq!(report.generated_interface_allocations.len(), 1); assert!(!report.semantic_defaults.is_empty()); let validated: Value = server .client .post(server.url(&format!("/projects/{resource}/validation"))) .json(&json!({"format":"source-project","source_project":old})) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(validated["valid"], true); assert_eq!(validated["migration"]["from_schema"], 3); assert_eq!( validated["migration"]["generated_interface_allocations"] .as_array() .unwrap() .len(), 1 ); assert_eq!(stored.source_project.files.len(), 2); assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 5;")); for text in [ "// Event rationale", "// Interface rationale", "interface_item_allocation", ] { assert!(stored.source_project.files["dictionary.arcadia"].contains(text)); } assert_pair(&stored); let filename = directory.path().join(format!("{resource}.json")); let bytes = std::fs::read(&filename).unwrap(); server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(resource).await; assert_eq!(reopened.model, stored.model); assert_eq!(reopened.source_project, stored.source_project); assert!(reopened.migration.is_none()); assert_eq!(std::fs::read(filename).unwrap(), bytes); server.stop().await; } #[tokio::test] async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_save() { let directory = tempfile::tempdir().unwrap(); let resource = Id::new_v4(); let filename = directory.path().join(format!("{resource}.json")); let old_model: Value = serde_json::from_str(include_str!( "../../../oa-core/tests/fixtures/v3-data-contract.json" )) .unwrap(); let old_bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([( "project.arcadia".into(), include_str!("../../../oa-core/tests/fixtures/v3-data-contract.arcadia").into(), )]), }; let original = serde_json::to_vec(&json!({"resource_id":resource,"revision":17,"model":old_model,"source_project":old_bundle})).unwrap(); std::fs::write(&filename, &original).unwrap(); let server = TestServer::start(directory.path(), None).await; let opened = server.get(resource).await; assert_eq!(opened.revision, 17); assert_eq!(opened.model.schema_version, 5); assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3); assert!(opened.source_project.files["project.arcadia"].contains("arcadia 5;")); assert_pair(&opened); assert_eq!(std::fs::read(&filename).unwrap(), original); assert_eq!( commands( &server, &opened, vec![Command::Rename( opened.model.id, "Reviewed data migration".into() )] ) .await .status(), StatusCode::OK ); let saved = server.get(resource).await; assert_eq!(saved.revision, 18); let bytes = std::fs::read(&filename).unwrap(); assert_ne!(bytes, original); server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(resource).await; assert_eq!(reopened.model, saved.model); assert_eq!(reopened.source_project, saved.source_project); assert_eq!(reopened.etag(), saved.etag()); assert!(reopened.migration.is_none()); assert_eq!(std::fs::read(&filename).unwrap(), bytes); server.stop().await; } #[tokio::test] async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart() { use oa_core::{ Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection, PortContract, RelationBody, ScenarioViewpoint, TypeRef, }; fn values(model: &Project, occurrence: Id) -> &[MessageValue] { let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = model.elements[&occurrence].shared() else { panic!("Expected message"); }; values } fn edited_values(model: &Project, occurrence: Id, next: Vec) -> Element { let mut element = model.elements[&occurrence].clone(); let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = element.shared_mut() else { panic!("Expected message"); }; *values = next; element } let mut model = documented_data(); let operation = id(&model, "navigate"); let coordinate = id(&model, "coordinate"); let located = id(&model, "located"); let latitude = id(&model, "latitude"); let origin = id(&model, "origin"); let destination = id(&model, "destination"); let accepted = id(&model, "accepted"); // The second parameter inherits the very same latitude identity. let Some(SharedBody::DataField { type_ref, .. }) = model.elements.get_mut(&destination).unwrap().shared_mut() else { panic!(); }; *type_ref = TypeRef::Named(located); let next = model .add_element( Kind::DataField, Layer::Sa, "Next coordinate", Some(coordinate), ) .unwrap(); *model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField { type_ref: TypeRef::Named(coordinate), minimum: 0, maximum: Some(1), direction: ParameterDirection::Unspecified, storage: FieldStorage::Value, unit: String::new(), default: None, }; let requester = model .add_element(Kind::Function, Layer::Sa, "Request navigation", None) .unwrap(); let provider = model .add_element(Kind::Function, Layer::Sa, "Provide navigation", None) .unwrap(); let output = Id::new_v4(); let input = Id::new_v4(); for (port, contract) in [ ( output, PortContract::FunctionOutput { outgoing_items: vec![operation], }, ), ( input, PortContract::FunctionInput { incoming_items: vec![operation], }, ), ] { let (owner, name) = if port == output { (requester, "Navigation output") } else { (provider, "Navigation input") }; let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap(); model.elements.insert(port, element); } let exchange = model.relate( RelationKind::FunctionalExchange, "Navigation request", requester, provider, ); model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { source: requester, target: provider, source_port: output, target_port: input, items: vec![operation], }; let scenario = model .add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None) .unwrap(); let Some(SharedBody::Scenario { viewpoint, .. }) = model.elements.get_mut(&scenario).unwrap().shared_mut() else { panic!(); }; *viewpoint = ScenarioViewpoint::Functional; let sender = model .add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario)) .unwrap(); let receiver = model .add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario)) .unwrap(); for (line, represented) in [(sender, requester), (receiver, provider)] { *model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { represented: Some(represented), }; } let call = model .add_element( Kind::Occurrence, Layer::Sa, "Navigation call", Some(scenario), ) .unwrap(); let reply = model .add_element( Kind::Occurrence, Layer::Sa, "Navigation reply", Some(scenario), ) .unwrap(); for (occurrence, message_kind, from, to, reply_to) in [ (call, MessageKind::Call, sender, receiver, None), (reply, MessageKind::Reply, receiver, sender, Some(call)), ] { *model .elements .get_mut(&occurrence) .unwrap() .shared_mut() .unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(exchange), sender: Some(from), receiver: Some(to), message_kind, reply_to, values: vec![], }, precondition: String::new(), postcondition: String::new(), }; } model .elements .get_mut(&scenario) .unwrap() .steps_mut() .unwrap() .extend([call, reply]); oa_core::validate_candidate(&model).unwrap(); let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let (resource, _) = server.import(&model).await; let before = server.get(resource).await; let call_values = vec![ MessageValue { path: vec![origin], field: latitude, expression: " origin.latitude\n+ 0.25 ".into(), }, MessageValue { path: vec![destination], field: latitude, expression: "destination.latitude".into(), }, MessageValue { path: vec![origin, next], field: latitude, expression: "previous.latitude".into(), }, ]; let reply_values = vec![MessageValue { path: vec![], field: accepted, expression: "true".into(), }]; let response = commands( &server, &before, vec![ Command::EditElement(call, edited_values(&model, call, call_values.clone())), Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())), ], ) .await; assert_eq!( response.status(), StatusCode::OK, "{}", response.text().await.unwrap() ); let authored = server.get(resource).await; assert_eq!(authored.revision, before.revision + 1); assert_eq!(values(&authored.model, call), call_values); assert_eq!(values(&authored.model, reply), reply_values); assert_eq!( authored.model.elements[&latitude], model.elements[&latitude] ); assert_pair(&authored); let filename = directory.path().join(format!("{resource}.json")); let bytes = std::fs::read(&filename).unwrap(); for (occurrence, value, code) in [ ( call, MessageValue { path: vec![], field: accepted, expression: "wrong call phase".into(), }, "message_parameter_phase", ), ( reply, MessageValue { path: vec![origin], field: latitude, expression: "wrong reply phase".into(), }, "message_parameter_phase", ), ( call, MessageValue { path: vec![origin, accepted], field: latitude, expression: "unrelated hop".into(), }, "message_value_field", ), ] { let invalid = edited_values(&authored.model, occurrence, vec![value]); let mut invalid_model = authored.model.clone(); invalid_model.elements.insert(occurrence, invalid.clone()); let response = commands( &server, &authored, vec![Command::EditElement(occurrence, invalid)], ) .await; assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let problem: Value = response.json().await.unwrap(); assert_eq!(problem["code"], "command_rejected", "{problem}"); assert!( problem["detail"].as_str().unwrap().starts_with(code), "{problem}" ); // Transactions report the first rejection; validation supplies located issues. let validation: Value = server .client .post(server.url(&format!("/projects/{resource}/validation"))) .json(&json!({"format":"json", "model":invalid_model})) .send() .await .unwrap() .error_for_status() .unwrap() .json() .await .unwrap(); assert_eq!(validation["valid"], false); assert!( validation["issues"] .as_array() .unwrap() .iter() .any(|issue| issue["code"] == code && issue["object_id"] == json!(occurrence) && issue["field"] == "values.path"), "{problem}" ); let unchanged = server.get(resource).await; assert_eq!(unchanged.etag(), authored.etag()); assert_eq!(unchanged.model, authored.model); assert_eq!(unchanged.source_project, authored.source_project); assert_eq!(std::fs::read(&filename).unwrap(), bytes); } // Source transport changes one argument without conflating the shared leaf. let source = server .client .get(server.url(&format!("/projects/{resource}/source"))) .send() .await .unwrap() .error_for_status() .unwrap() .text() .await .unwrap(); let edited_source = source.replace( "\"destination.latitude\"", "\"destination.corrected_latitude\"", ); assert_ne!(edited_source, source); let response = server .client .put(server.url(&format!("/projects/{resource}/source"))) .header("if-match", authored.etag()) .header("content-type", "text/plain") .body(edited_source) .send() .await .unwrap(); assert_eq!( response.status(), StatusCode::OK, "{}", response.text().await.unwrap() ); let source_edited = server.get(resource).await; assert_eq!(source_edited.revision, authored.revision + 1); let mut expected_call = call_values.clone(); expected_call[1].expression = "destination.corrected_latitude".into(); assert_eq!(values(&source_edited.model, call), expected_call); assert_eq!(values(&source_edited.model, reply), reply_values); assert_pair(&source_edited); // Reidentify every kind of enclosing hop, the shared terminal, and its base type. let mapping = [origin, destination, next, latitude, coordinate] .into_iter() .map(|old| (old, Id::new_v4())) .collect::>(); let response = commands( &server, &source_edited, mapping .iter() .map(|(old, new)| Command::Reidentify(*old, *new)) .collect(), ) .await; assert_eq!( response.status(), StatusCode::OK, "{}", response.text().await.unwrap() ); let changed = server.get(resource).await; assert_eq!(changed.revision, source_edited.revision + 1); for value in &mut expected_call { value.path = value.path.iter().map(|hop| mapping[hop]).collect(); value.field = mapping[&value.field]; } assert_eq!(values(&changed.model, call), expected_call); assert_eq!(values(&changed.model, reply), reply_values); assert_eq!( changed.model.elements[&mapping[&latitude]].parent, Some(mapping[&coordinate]) ); assert!( matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Record { supertype: Some(base) }, .. }) if *base == mapping[&coordinate]) ); assert!( oa_core::data::effective_fields(&changed.model, located) .iter() .any(|field| field.id == mapping[&latitude]) ); for old in mapping.keys() { assert!(!changed.model.contains(*old)); } assert!( matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, .. }) if *correlation == call) ); assert_pair(&changed); let source = server .client .get(server.url(&format!("/projects/{resource}/source"))) .send() .await .unwrap() .error_for_status() .unwrap() .text() .await .unwrap(); assert!(source.starts_with("arcadia 5;")); assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model); let saved = std::fs::read(&filename).unwrap(); server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(resource).await; assert_eq!(reopened.etag(), changed.etag()); assert_eq!(reopened.model, changed.model); assert_eq!(reopened.source_project, changed.source_project); assert_eq!(values(&reopened.model, call), expected_call); assert_eq!(values(&reopened.model, reply), reply_values); assert_pair(&reopened); assert_eq!(std::fs::read(&filename).unwrap(), saved); server.stop().await; }