NEXT predates the access work, so the identity layer follows master: access comes from configured groups, instance administrators replace the site administration, and accounts are created by SSO or by an administrator. Kept from NEXT: manual publication with the Review changes panel and shared generator drafts, portable import/export (meguri/v2), the layered generation map with binding provenance, JSM provider changes, contact/approval semantics and the account-owned Person. Dropped: site administration, local sign-up, and the mandatory unique email with its contact/account migrations. The manual-publication migration is renumbered m_0010 after m_0009_access. Accounts without an email still get a Person in every workspace they reach, a changed sign-in email updates the account's person, and an account that loses access keeps its linked person so schedules stay valid. Instance administrators may use Sync and shared drafts. Co-Authored-By: Claude <noreply@anthropic.com>
461 lines
17 KiB
Rust
461 lines
17 KiB
Rust
use meguri::{demo, domain::*, generation, generation_service::Draft, portable};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
fn fixture() -> (Uuid, WorkspaceDocument, Draft) {
|
|
let workspace = Uuid::new_v4();
|
|
let a = Uuid::new_v4();
|
|
let b = Uuid::new_v4();
|
|
let (variables, rules) = demo::project_matrix("2026-09-28".parse().unwrap(), a, b);
|
|
let mut doc = WorkspaceDocument::new("Portable".into());
|
|
for (id, email) in [(a, "alice@example.com"), (b, "bob@example.com")] {
|
|
doc.people.push(Person {
|
|
id,
|
|
user_id: None,
|
|
display_name: email.into(),
|
|
email: Some(email.into()),
|
|
country: Some("CY".into()),
|
|
aliases: vec![],
|
|
});
|
|
}
|
|
(
|
|
workspace,
|
|
doc,
|
|
Draft {
|
|
revision: 1,
|
|
shared_revision: Some(0),
|
|
variables,
|
|
rules,
|
|
route_position: 0,
|
|
review_id: None,
|
|
},
|
|
)
|
|
}
|
|
#[test]
|
|
fn export_roundtrip_preserves_ids_people_bindings_and_omitted_inputs() {
|
|
let (ws, doc, draft) = fixture();
|
|
let source = portable::export_v1(&doc, &draft).unwrap();
|
|
let text = source.to_string();
|
|
for p in &doc.people {
|
|
assert!(!text.contains(&p.id.to_string()));
|
|
}
|
|
assert!(text.contains("alice@example.com"));
|
|
let (imported, changes) = portable::import(ws, &doc, &draft, &text).unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(&draft).unwrap(),
|
|
serde_json::to_value(&imported).unwrap()
|
|
);
|
|
assert!(
|
|
changes
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|c| c["action"] == "unchanged")
|
|
);
|
|
let empty = json!({"format":"meguri/v1","variables":[],"generators":[]});
|
|
let (retained, _) = portable::import(ws, &doc, &draft, &empty.to_string()).unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(retained).unwrap(),
|
|
serde_json::to_value(draft).unwrap()
|
|
);
|
|
}
|
|
#[test]
|
|
fn transfer_between_workspaces_remaps_and_repeat_import_is_stable() {
|
|
let (_, doc, draft) = fixture();
|
|
let source = portable::export_v1(&doc, &draft).unwrap().to_string();
|
|
let target = Uuid::new_v4();
|
|
let mut target_doc = doc.clone();
|
|
for p in &mut target_doc.people {
|
|
p.id = Uuid::new_v4();
|
|
}
|
|
let base = Draft {
|
|
variables: vec![],
|
|
rules: vec![],
|
|
..draft.clone()
|
|
};
|
|
let (first, _) = portable::import(target, &target_doc, &base, &source).unwrap();
|
|
assert_ne!(first.variables[0].id, draft.variables[0].id);
|
|
assert_ne!(first.rules[0].blocks[0].id, draft.rules[0].blocks[0].id);
|
|
let (second, changes) = portable::import(target, &target_doc, &first, &source).unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(&first).unwrap(),
|
|
serde_json::to_value(&second).unwrap()
|
|
);
|
|
assert!(
|
|
changes
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|c| c["action"] == "unchanged")
|
|
);
|
|
let result = generation::expand(
|
|
&target_doc,
|
|
target,
|
|
&first.variables,
|
|
&first.rules,
|
|
0,
|
|
&target_doc.working_interval,
|
|
&Default::default(),
|
|
chrono::Utc::now(),
|
|
);
|
|
assert!(result.issues.is_empty(), "{:?}", result.issues);
|
|
assert!(result.document.schedules.iter().all(|s| {
|
|
match &s.kind {
|
|
ScheduleKind::Rotation { participants, .. }
|
|
| ScheduleKind::Priority { participants, .. } => participants.iter().all(|id| {
|
|
result.document.people.iter().any(|p| p.id == *id)
|
|
&& !doc.people.iter().any(|p| p.id == *id)
|
|
}),
|
|
}
|
|
}));
|
|
}
|
|
#[test]
|
|
fn malformed_ambiguous_unknown_fields_and_missing_sources_are_rejected() {
|
|
let (ws, doc, draft) = fixture();
|
|
assert!(portable::import(ws, &doc, &draft, "{").is_err());
|
|
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
|
v["integration"] = json!({"token":"never-accepted"});
|
|
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
|
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
|
v["format"] = json!("meguri/v999");
|
|
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
|
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
|
let duplicate = v["variables"][0].clone();
|
|
v["variables"].as_array_mut().unwrap().push(duplicate);
|
|
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
|
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
|
v["generators"][0]["blocks"][0]["fields"]["participants"] =
|
|
json!({"kind":"variable","variable":"missing"});
|
|
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
|
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
|
v["generators"][0]["blocks"][0]["typo"] = json!(true);
|
|
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
|
}
|
|
#[test]
|
|
fn contacts_are_email_only_and_missing_export_email_blocks() {
|
|
let (ws, mut doc, draft) = fixture();
|
|
doc.people[0].email = None;
|
|
assert!(portable::export_v1(&doc, &draft).is_err());
|
|
let text = json!({"format":"meguri/v1","variables":[{"name":"new contact","node":{"kind":"person","value":"new@example.com"}}],"generators":[]});
|
|
let (out, _) = portable::import(ws, &doc, &draft, &text.to_string()).unwrap();
|
|
assert_eq!(out.variables.last().unwrap().node.value, "new@example.com");
|
|
assert_eq!(doc.people.len(), 2);
|
|
}
|
|
#[test]
|
|
fn manual_reference_is_mapped_by_exact_name_and_is_not_created() {
|
|
let (ws, mut doc, draft) = fixture();
|
|
let id = Uuid::new_v4();
|
|
doc.schedules.push(Schedule {
|
|
id,
|
|
name: "Fallback".into(),
|
|
handover: default_handover(),
|
|
kind: ScheduleKind::Rotation {
|
|
participants: vec![doc.people[0].id],
|
|
anchor: "2026-09-28".parse().unwrap(),
|
|
shift_days: 7,
|
|
},
|
|
});
|
|
let config = json!({"format":"meguri/v1","variables":[],"generators":[{"name":"With fallback","blocks":[{"id":"owners","kind":"priority","fields":{"name":{"kind":"literal","value":"Owners"},"participants":{"kind":"literal","value":["alice@example.com"]},"fallback":{"kind":"literal","value":"Fallback"}}}]}]});
|
|
let (imported, _) = portable::import(ws, &doc, &draft, &config.to_string()).unwrap();
|
|
assert_eq!(
|
|
imported.rules.last().unwrap().blocks[0].fields["fallback"].value,
|
|
json!(id)
|
|
);
|
|
doc.schedules.clear();
|
|
assert!(portable::import(ws, &doc, &draft, &config.to_string()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn blank_text_survives_export_import() {
|
|
let (ws, doc, mut draft) = fixture();
|
|
draft.variables.push(generation::Variable {
|
|
id: Uuid::new_v4(),
|
|
name: "blank".into(),
|
|
node: generation::Node {
|
|
id: Uuid::new_v4(),
|
|
kind: "text".into(),
|
|
key: String::new(),
|
|
value: json!(""),
|
|
entries: vec![],
|
|
},
|
|
});
|
|
let text = portable::export_v1(&doc, &draft).unwrap().to_string();
|
|
let (out, _) = portable::import(ws, &doc, &draft, &text).unwrap();
|
|
assert_eq!(out.variables.last().unwrap().node.value, "");
|
|
}
|
|
|
|
#[test]
|
|
fn complex_lookup_conditions_and_nested_paths_roundtrip() {
|
|
let (ws, doc, mut draft) = fixture();
|
|
for create in [demo::regional_platform, demo::service_tiers] {
|
|
let (variables, rules) = create(
|
|
"2026-09-28".parse().unwrap(),
|
|
doc.people[0].id,
|
|
doc.people[1].id,
|
|
);
|
|
draft.variables = variables;
|
|
draft.rules = rules;
|
|
let source = portable::export_v1(&doc, &draft).unwrap();
|
|
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(out).unwrap(),
|
|
serde_json::to_value(&draft).unwrap()
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn person_predicate_constants_transfer_by_email() {
|
|
let (ws, doc, mut draft) = fixture();
|
|
draft.rules[0].when = Some(generation::Predicate {
|
|
left: generation::Binding {
|
|
kind: "literal".into(),
|
|
value: json!(doc.people[0].id),
|
|
..Default::default()
|
|
},
|
|
operation: "equals".into(),
|
|
right: generation::Binding {
|
|
kind: "literal".into(),
|
|
value: json!(doc.people[1].id),
|
|
..Default::default()
|
|
},
|
|
});
|
|
let source = portable::export_v1(&doc, &draft).unwrap();
|
|
assert_eq!(
|
|
source["generators"][0]["when"]["left"]["value"]["$person"],
|
|
"alice@example.com"
|
|
);
|
|
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
|
|
assert_eq!(
|
|
out.rules[0].when.as_ref().unwrap().left.value,
|
|
json!(doc.people[0].id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_sample_preserves_resources_without_uuids_or_cached_binding_values() {
|
|
let (ws, doc, mut base) = fixture();
|
|
base.variables.clear();
|
|
base.rules.clear();
|
|
let source = include_str!("fixtures/portable-v1.json");
|
|
let (original, _) = portable::import(ws, &doc, &base, source).unwrap();
|
|
let compact = portable::export(&doc, &original).unwrap();
|
|
let text = serde_json::to_string_pretty(&compact).unwrap();
|
|
assert!(
|
|
text.len() * 3 < source.len(),
|
|
"{} vs {}",
|
|
text.len(),
|
|
source.len()
|
|
);
|
|
assert!(!text.contains("14e49"));
|
|
for r in &original.rules {
|
|
for b in &r.blocks {
|
|
assert!(!text.contains(&b.id.to_string()));
|
|
}
|
|
}
|
|
assert!(!text.contains("New First available"));
|
|
assert!(!text.contains("team_all"));
|
|
assert!(!text.contains("\"kind\""));
|
|
let (imported, _) = portable::import(ws, &doc, &original, &text).unwrap();
|
|
let now = chrono::Utc::now();
|
|
let expand = |draft: &Draft| {
|
|
generation::expand(
|
|
&doc,
|
|
ws,
|
|
&draft.variables,
|
|
&draft.rules,
|
|
0,
|
|
&doc.working_interval,
|
|
&Default::default(),
|
|
now,
|
|
)
|
|
};
|
|
let before = expand(&original);
|
|
let after = expand(&imported);
|
|
assert!(before.issues.is_empty(), "{:?}", before.issues);
|
|
assert!(after.issues.is_empty(), "{:?}", after.issues);
|
|
for (a, b) in [
|
|
(
|
|
json!(before.document.schedules),
|
|
json!(after.document.schedules),
|
|
),
|
|
(
|
|
json!(before.document.escalations),
|
|
json!(after.document.escalations),
|
|
),
|
|
(json!(before.document.routes), json!(after.document.routes)),
|
|
] {
|
|
assert_eq!(a, b);
|
|
}
|
|
let (again, _) = portable::import(ws, &doc, &imported, &text).unwrap();
|
|
assert_eq!(json!(imported), json!(again));
|
|
}
|
|
|
|
#[test]
|
|
fn compact_types_order_escapes_and_output_keys_survive_reordering() {
|
|
let (ws, doc, base) = fixture();
|
|
let value = json!({"format":"meguri/v2","variables":{"typed":{"$object":[["z",{"$date":"2026-10-01"}],["a",{"$time":"09:00"}],["duration",{"$duration":30}],["text",{"$text":"literal@example.com"}],["$item","plain"]]}},"generators":[]});
|
|
let (draft, _) = portable::import(ws, &doc, &base, &value.to_string()).unwrap();
|
|
let out = portable::export(&doc, &draft).unwrap();
|
|
assert_eq!(out["variables"]["typed"], value["variables"]["typed"]);
|
|
let mut compact = portable::export(&doc, &base).unwrap();
|
|
let (first, _) = portable::import(ws, &doc, &base, &compact.to_string()).unwrap();
|
|
compact["generators"][0]["outputs"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.reverse();
|
|
let (reordered, _) = portable::import(ws, &doc, &first, &compact.to_string()).unwrap();
|
|
let mut a: Vec<_> = first.rules[0].blocks.iter().map(|b| b.id).collect();
|
|
let mut b: Vec<_> = reordered.rules[0].blocks.iter().map(|b| b.id).collect();
|
|
a.sort();
|
|
b.sort();
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_named_list_reordering_and_field_edit_preserve_entry_ids() {
|
|
let (ws, doc, base) = fixture();
|
|
let mut v = json!({"format":"meguri/v2","variables":{"projects":[{"name":"a","people":["alice@example.com"]},{"name":"b","people":["bob@example.com"]}]},"generators":[]});
|
|
let (first, _) = portable::import(ws, &doc, &base, &v.to_string()).unwrap();
|
|
let old = first
|
|
.variables
|
|
.iter()
|
|
.find(|v| v.name == "projects")
|
|
.unwrap();
|
|
v["variables"]["projects"].as_array_mut().unwrap().reverse();
|
|
v["variables"]["projects"][0]["people"] = json!(["carol@example.com"]);
|
|
let (second, _) = portable::import(ws, &doc, &first, &v.to_string()).unwrap();
|
|
let new = second
|
|
.variables
|
|
.iter()
|
|
.find(|v| v.name == "projects")
|
|
.unwrap();
|
|
assert_eq!(old.node.entries[0].id, new.node.entries[1].id);
|
|
assert_eq!(old.node.entries[1].id, new.node.entries[0].id);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_cross_workspace_and_complex_bindings_keep_effective_configuration() {
|
|
let (ws, doc, mut draft) = fixture();
|
|
for create in [demo::regional_platform, demo::service_tiers] {
|
|
(draft.variables, draft.rules) = create(
|
|
"2026-09-28".parse().unwrap(),
|
|
doc.people[0].id,
|
|
doc.people[1].id,
|
|
);
|
|
let value = portable::export(&doc, &draft).unwrap();
|
|
let text = portable::format_config(&value);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&text).unwrap(),
|
|
value
|
|
);
|
|
let (same, _) = portable::import(ws, &doc, &draft, &text).unwrap();
|
|
let now = chrono::Utc::now();
|
|
let expand = |d: &Draft, workspace| {
|
|
generation::expand(
|
|
&doc,
|
|
workspace,
|
|
&d.variables,
|
|
&d.rules,
|
|
0,
|
|
&doc.working_interval,
|
|
&Default::default(),
|
|
now,
|
|
)
|
|
};
|
|
let before = expand(&draft, ws);
|
|
let after = expand(&same, ws);
|
|
assert!(after.issues.is_empty(), "{:?}", after.issues);
|
|
assert_eq!(
|
|
json!(before.document.schedules),
|
|
json!(after.document.schedules)
|
|
);
|
|
assert_eq!(
|
|
json!(before.document.escalations),
|
|
json!(after.document.escalations)
|
|
);
|
|
assert_eq!(json!(before.document.routes), json!(after.document.routes));
|
|
let target = Uuid::new_v4();
|
|
let empty = Draft {
|
|
variables: vec![],
|
|
rules: vec![],
|
|
..draft.clone()
|
|
};
|
|
let (first, _) = portable::import(target, &doc, &empty, &text).unwrap();
|
|
let (again, _) = portable::import(target, &doc, &first, &text).unwrap();
|
|
assert_eq!(json!(first), json!(again));
|
|
assert!(expand(&first, target).issues.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn guide_example_compiles_to_all_four_output_types() {
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(include_str!("../static/locales/messages.json")).unwrap();
|
|
let (ws, doc, mut base) = fixture();
|
|
base.variables.clear();
|
|
base.rules.clear();
|
|
for language in ["en", "ru", "fr"] {
|
|
let source = catalog["transfer_guide_example"][language]
|
|
.as_str()
|
|
.unwrap();
|
|
let (draft, _) = portable::import(ws, &doc, &base, source).unwrap();
|
|
let expanded = generation::expand(
|
|
&doc,
|
|
ws,
|
|
&draft.variables,
|
|
&draft.rules,
|
|
0,
|
|
&doc.working_interval,
|
|
&Default::default(),
|
|
chrono::Utc::now(),
|
|
);
|
|
assert!(
|
|
expanded.issues.is_empty(),
|
|
"{language}: {:?}",
|
|
expanded.issues
|
|
);
|
|
assert_eq!(expanded.document.schedules.len(), 2);
|
|
assert_eq!(expanded.document.escalations.len(), 1);
|
|
assert_eq!(expanded.document.routes.len(), 1);
|
|
assert!(matches!(
|
|
expanded.document.escalations[0].steps[1].recipient,
|
|
Recipient::TeamAll
|
|
));
|
|
assert_eq!(expanded.document.escalations[0].steps[1].delay_minutes, 30);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn random_team_import_roundtrips_and_rejects_unknown_modes() {
|
|
let (ws, doc, mut base) = fixture();
|
|
base.variables.clear();
|
|
base.rules.clear();
|
|
let mut source = json!({"format":"meguri/v2","variables":{},"generators":[{"name":"Random","outputs":[{"key":"policy","type":"escalation","name":"Random","steps":[{"after":30,"team":"random"}]}]}]});
|
|
let (draft, _) = portable::import(ws, &doc, &base, &source.to_string()).unwrap();
|
|
let out = generation::expand(
|
|
&doc,
|
|
ws,
|
|
&draft.variables,
|
|
&draft.rules,
|
|
0,
|
|
&doc.working_interval,
|
|
&Default::default(),
|
|
chrono::Utc::now(),
|
|
);
|
|
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
|
assert!(matches!(
|
|
out.document.escalations[0].steps[0].recipient,
|
|
Recipient::TeamRandom
|
|
));
|
|
assert_eq!(out.document.escalations[0].steps[0].delay_minutes, 30);
|
|
for export in [
|
|
portable::export(&doc, &draft).unwrap(),
|
|
portable::export_v1(&doc, &draft).unwrap(),
|
|
] {
|
|
let (roundtrip, _) = portable::import(ws, &doc, &draft, &export.to_string()).unwrap();
|
|
assert!(roundtrip.rules[0].blocks[0].steps[0].team_random);
|
|
assert!(!roundtrip.rules[0].blocks[0].steps[0].team_all);
|
|
}
|
|
source["generators"][0]["outputs"][0]["steps"][0]["team"] = json!("sequential");
|
|
assert!(portable::import(ws, &doc, &base, &source.to_string()).is_err());
|
|
}
|