//! Real HTTP checks for caller-instance identity and operation allocation identity. use super::*; use oa_core::{ ContextualLinkPurpose, Element, OccurrenceBody, OccurrenceEndpoint, OperationUse, Relation, RelationBody, SharedBody, language, persistence, source_project::{SourceBundle, compile_bundle}, }; use std::collections::BTreeMap; fn operation_id(n: u128) -> Id { Id::from_u128(0x52060906000040008000000000000000 + n) } fn occurrence(model: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { let mut element = model .make_element(Kind::Occurrence, Layer::Oa, name, Some(owner)) .unwrap(); *element.shared_mut().unwrap() = SharedBody::Occurrence { body, precondition: String::new(), postcondition: String::new(), }; let id = element.id; model.elements.insert(id, element); model .elements .get_mut(&owner) .unwrap() .steps_mut() .unwrap() .push(id); id } fn select_operation(model: &Project, allocation: Id) -> Element { let mut element = model.elements[&operation_id(43)].clone(); let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation, .. }, .. }) = element.shared_mut() else { panic!("fixture Call"); }; *operation = Some(OperationUse { allocation: Some(allocation), }); element } struct Fixture { model: Project, bundle: SourceBundle, definition: Id, r1: Id, r2: Id, link: Id, } fn fixture() -> Fixture { // Frozen source6 absence probe contains two parameterless operations on one // concrete boundary. Its old bytes remain compatibility inputs. let mut model = persistence::decode(include_bytes!( "../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" )) .unwrap(); let selected = select_operation(&model, operation_id(30)); model.elements.insert(selected.id, selected); let caller = model .add_element(Kind::Process, Layer::Oa, "Caller", None) .unwrap(); let definition = model .add_element(Kind::Process, Layer::Oa, "Reusable assessment", None) .unwrap(); let work = model .add_element(Kind::Activity, Layer::Oa, "Assess", None) .unwrap(); let leaf = occurrence( &mut model, definition, "Assessment contribution", OccurrenceBody::Action { action: Some(work), lifeline: None, }, ); let x = occurrence( &mut model, caller, "Caller check", OccurrenceBody::Action { action: Some(work), lifeline: None, }, ); let r1 = occurrence( &mut model, caller, "First assessment", OccurrenceBody::SequenceReference { sequence: Some(definition), }, ); let r2 = occurrence( &mut model, caller, "Second assessment", OccurrenceBody::SequenceReference { sequence: Some(definition), }, ); let link = Id::new_v4(); model.relations.insert( link, Relation { id: link, alias: "qualified_assessment".into(), name: "Check the first assessment".into(), description: "Caller intent survives source and REST editing.".into(), body: RelationBody::ContextualLink { owner: caller, source: OccurrenceEndpoint { references: vec![], occurrence: x, }, target: OccurrenceEndpoint { references: vec![r1], occurrence: leaf, }, purpose: ContextualLinkPurpose::Precedence { guard: "check accepted".into(), }, }, }, ); let source = language::format(&model).replacen("{\n", "{\n include \"rationale.arcadia\";\n", 1); let bundle = SourceBundle { entry: "assembly.arcadia".into(), files: BTreeMap::from([ ("assembly.arcadia".into(), source), ( "rationale.arcadia".into(), "// Authored rationale: retain 雨 and CRLF.\r\n".into(), ), ]), }; assert_eq!( compile_bundle(&bundle, Default::default()) .unwrap() .decoded .project, model ); Fixture { model, bundle, definition, r1, r2, link, } } async fn transact( server: &TestServer, stored: &StoredProject, commands: Vec, ) -> reqwest::Response { server .client .post(server.url(&format!("/projects/{}/transactions", stored.resource_id))) .header("if-match", stored.etag()) .json(&json!({"commands": commands})) .send() .await .unwrap() } async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, bytes: &[u8]) { let after = server.get(before.resource_id).await; assert_eq!(after.model, before.model); assert_eq!(after.source_project, before.source_project); assert_eq!(after.revision, before.revision); assert_eq!(after.etag(), before.etag()); assert_eq!(std::fs::read(disk).unwrap(), bytes); } #[tokio::test] async fn source7_http_instance_and_operation_edits_preserve_identity_source_and_restart() { let f = fixture(); let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let response = server .client .post(server.url("/projects/import-source-project")) .json(&f.bundle) .send() .await .unwrap(); let status = response.status(); let body: Value = response.json().await.unwrap(); assert_eq!(status, StatusCode::CREATED, "{body}"); let id: Id = body["resource_id"].as_str().unwrap().parse().unwrap(); let before = server.get(id).await; assert_eq!(before.model, f.model); assert_eq!(before.source_project, f.bundle); let queried: Value = server .client .get(server.url(&format!("/projects/{id}/relations?kind=ContextualLink"))) .send() .await .unwrap() .error_for_status() .unwrap() .json() .await .unwrap(); assert_eq!(queried["items"].as_array().unwrap().len(), 1); assert_eq!(queried["items"][0]["id"], f.link.to_string()); // Only two explicit records change: caller-instance address and allocation. // The shared procedure, both reference instances and operation items persist. let mut relation = before.model.relations[&f.link].clone(); if let RelationBody::ContextualLink { target, .. } = &mut relation.body { target.references = vec![f.r2]; } let selected = select_operation(&before.model, operation_id(31)); let response = transact( &server, &before, vec![ Command::EditRelation(f.link, relation.clone()), Command::EditElement(selected.id, selected.clone()), ], ) .await; let status = response.status(); let body: Value = response.json().await.unwrap(); assert_eq!(status, StatusCode::OK, "{body}"); let after = server.get(id).await; let mut expected = before.model.clone(); expected.relations.insert(f.link, relation); expected.elements.insert(selected.id, selected); assert_eq!(after.model, expected); assert_eq!(after.revision, before.revision + 1); assert_eq!( after.source_project.files["rationale.arcadia"], f.bundle.files["rationale.arcadia"] ); assert_eq!( compile_bundle(&after.source_project, Default::default()) .unwrap() .decoded .project, expected ); assert_eq!( after.model.elements[&f.definition], before.model.elements[&f.definition] ); assert_eq!(after.model.elements[&f.r1], before.model.elements[&f.r1]); let disk = directory.path().join(format!("{id}.json")); let bytes = std::fs::read(&disk).unwrap(); let mut bad_link = after.model.relations[&f.link].clone(); if let RelationBody::ContextualLink { target, .. } = &mut bad_link.body { target.references = vec![f.definition]; // Definition is not an instance. } let wrong_operation = select_operation(&after.model, operation_id(20)); // Item is not an allocation. for commands in [ vec![Command::EditRelation(f.link, bad_link)], vec![Command::EditElement(wrong_operation.id, wrong_operation)], ] { let response = transact(&server, &after, commands).await; assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); unchanged(&server, &after, &disk, &bytes).await; } let mut old_json = serde_json::to_value(&after.model).unwrap(); old_json["schema_version"] = json!(6); // Isolate admission gates: one new feature must not mask another. let mut context_only = old_json.clone(); for element in context_only["elements"] .as_object_mut() .unwrap() .values_mut() { if let Some(body) = element.pointer_mut("/body/Sa/Shared/Occurrence/body/Exchange") { body.as_object_mut().unwrap().remove("operation"); } } let mut operation_null_only: Value = serde_json::from_slice(include_bytes!( "../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" )) .unwrap(); operation_null_only["elements"][operation_id(43).to_string()]["body"]["Sa"]["Shared"]["Occurrence"] ["body"]["Exchange"]["operation"] = Value::Null; for candidate in [old_json, context_only, operation_null_only] { let response = server .client .put(server.url(&format!("/projects/{id}/model"))) .header("if-match", after.etag()) .json(&candidate) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); unchanged(&server, &after, &disk, &bytes).await; } let mut old_source = after.source_project.clone(); let entry = old_source.files.get_mut(&old_source.entry).unwrap(); *entry = entry.replacen("arcadia 7;", "arcadia 6;", 1); let response = server .client .put(server.url(&format!("/projects/{id}/source-project"))) .header("if-match", after.etag()) .json(&old_source) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); unchanged(&server, &after, &disk, &bytes).await; server.stop().await; let server = TestServer::start(directory.path(), None).await; unchanged(&server, &after, &disk, &bytes).await; server.stop().await; }