Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
693 lines
22 KiB
Rust
693 lines
22 KiB
Rust
use chrono::{Duration, NaiveDate, Utc};
|
||
use meguri::{
|
||
demo,
|
||
domain::*,
|
||
engine,
|
||
generation::{self, Binding, Rule, Variable},
|
||
};
|
||
use serde_json::json;
|
||
use std::collections::BTreeSet;
|
||
use uuid::Uuid;
|
||
|
||
struct Example {
|
||
doc: WorkspaceDocument,
|
||
team: Uuid,
|
||
people: [Uuid; 2],
|
||
variables: Vec<Variable>,
|
||
rules: Vec<Rule>,
|
||
day: NaiveDate,
|
||
}
|
||
impl Example {
|
||
fn new() -> Self {
|
||
let people = [Uuid::new_v4(), Uuid::new_v4()];
|
||
let day = "2026-10-19".parse().unwrap();
|
||
let (variables, rules) = demo::project_matrix(day, people[0], people[1]);
|
||
let mut doc = WorkspaceDocument::new("Projects".into());
|
||
doc.timezone = "Asia/Nicosia".into();
|
||
Self {
|
||
doc,
|
||
team: Uuid::new_v4(),
|
||
people,
|
||
variables,
|
||
rules,
|
||
day,
|
||
}
|
||
}
|
||
fn expand(&self) -> generation::Expansion {
|
||
generation::expand(
|
||
&self.doc,
|
||
self.team,
|
||
&self.variables,
|
||
&self.rules,
|
||
0,
|
||
&WorkingWindow {
|
||
timezone: self.doc.timezone.clone(),
|
||
..WorkingWindow::default()
|
||
},
|
||
&self.people.into_iter().collect(),
|
||
engine::handover(&self.doc, self.day).unwrap(),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn matrix_keeps_project_priority_and_weekly_monday_handover_across_dst() {
|
||
let e = Example::new();
|
||
let out = e.expand();
|
||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||
assert_eq!(out.document.schedules.len(), 4);
|
||
assert_eq!(out.document.routes.len(), 4);
|
||
assert!(
|
||
out.document.routes[..3]
|
||
.iter()
|
||
.all(|r| r.window.as_ref().unwrap().timezone == "Asia/Nicosia")
|
||
);
|
||
assert!(out.document.routes[3].conditions.is_empty());
|
||
assert!(out.document.routes[3].window.is_none());
|
||
let duty = out
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Time off")
|
||
.unwrap();
|
||
let project = out
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Payments · owners")
|
||
.unwrap();
|
||
let from = engine::handover(&out.document, e.day).unwrap();
|
||
let to = engine::handover(&out.document, e.day + Duration::days(14)).unwrap();
|
||
let timeline = engine::timeline(&out.document, from, to).unwrap();
|
||
let shifts: Vec<_> = timeline
|
||
.assignments
|
||
.iter()
|
||
.filter(|a| a.schedule_id == duty.id)
|
||
.collect();
|
||
assert_eq!(shifts.len(), 2);
|
||
assert_eq!(shifts[0].user_id, e.people[0]);
|
||
assert_eq!(shifts[1].user_id, e.people[1]);
|
||
assert_eq!((shifts[0].end - shifts[0].start).num_hours(), 169);
|
||
assert_eq!(shifts[0].end, shifts[1].start);
|
||
assert!(
|
||
timeline
|
||
.assignments
|
||
.iter()
|
||
.filter(|a| a.schedule_id == project.id)
|
||
.all(|a| a.user_id == e.people[0])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
|
||
let e = Example::new();
|
||
let mut doc = e.expand().document;
|
||
let project = doc
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Payments · owners")
|
||
.unwrap()
|
||
.id;
|
||
let duty = doc
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Time off")
|
||
.unwrap()
|
||
.id;
|
||
let third = doc.people[0].id;
|
||
doc.people.extend(e.people.map(|id| Person {
|
||
id,
|
||
user_id: Some(id),
|
||
email: None,
|
||
display_name: "Person".into(),
|
||
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
|
||
aliases: vec![],
|
||
}));
|
||
doc.holidays.push(Holiday {
|
||
country: "CY".into(),
|
||
date: e.day,
|
||
name: "Holiday".into(),
|
||
});
|
||
let from = engine::handover(&doc, e.day).unwrap();
|
||
let to = engine::handover(&doc, e.day + Duration::days(1)).unwrap();
|
||
let owner = |doc: &WorkspaceDocument| {
|
||
engine::timeline(doc, from, to)
|
||
.unwrap()
|
||
.assignments
|
||
.into_iter()
|
||
.find(|a| a.schedule_id == project)
|
||
.unwrap()
|
||
.user_id
|
||
};
|
||
assert_eq!(owner(&doc), e.people[1]);
|
||
doc.absences.push(Absence {
|
||
id: Uuid::new_v4(),
|
||
user_id: e.people[1],
|
||
start: e.day,
|
||
end: e.day,
|
||
});
|
||
// The third email contact is CY too, so CY's holiday skips it independently.
|
||
assert_eq!(owner(&doc), e.people[0]);
|
||
doc.holidays.clear();
|
||
doc.absences.push(Absence {
|
||
id: Uuid::new_v4(),
|
||
user_id: e.people[0],
|
||
start: e.day,
|
||
end: e.day,
|
||
});
|
||
assert_eq!(owner(&doc), third);
|
||
doc.absences.push(Absence {
|
||
id: Uuid::new_v4(),
|
||
user_id: third,
|
||
start: e.day,
|
||
end: e.day,
|
||
});
|
||
let substitute = Uuid::new_v4();
|
||
doc.people.push(Person {
|
||
id: substitute,
|
||
user_id: None,
|
||
email: Some("cover@example.com".into()),
|
||
display_name: "Cover".into(),
|
||
country: Some("CY".into()),
|
||
aliases: vec![],
|
||
});
|
||
doc.requests.push(ReplacementRequest {
|
||
effective_legs: None,
|
||
id: Uuid::new_v4(),
|
||
absence_id: None,
|
||
requested_by: e.people[0],
|
||
reason: "Cover".into(),
|
||
applied_at: Some(from),
|
||
forced_by: Some(e.people[0]),
|
||
created_at: from - Duration::days(1),
|
||
legs: vec![ReplacementLeg {
|
||
schedule_id: duty,
|
||
start: from,
|
||
end: to,
|
||
from_user: e.people[0],
|
||
to_user: substitute,
|
||
}],
|
||
status: RequestStatus::Applied,
|
||
required_approvers: BTreeSet::new(),
|
||
approvals: BTreeSet::new(),
|
||
force_reason: Some("Cover".into()),
|
||
});
|
||
assert_eq!(owner(&doc), substitute);
|
||
}
|
||
|
||
#[test]
|
||
fn renames_and_reorders_preserve_ids_and_approved_replacements_block_removal() {
|
||
let mut e = Example::new();
|
||
let first = e.expand();
|
||
assert!(first.issues.is_empty());
|
||
e.doc = first.document;
|
||
let old_ids: BTreeSet<_> = e.doc.schedules.iter().map(|s| s.id).collect();
|
||
e.variables[0].name = "Products".into();
|
||
e.variables[0].node.entries.reverse();
|
||
let entry = e.variables[0]
|
||
.node
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.value == "Payments")
|
||
.unwrap();
|
||
entry.value = json!("Payments v2");
|
||
e.variables[1]
|
||
.node
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "Payments")
|
||
.unwrap()
|
||
.key = "Payments v2".into();
|
||
let again = e.expand();
|
||
assert!(again.issues.is_empty(), "{:?}", again.issues);
|
||
assert!(
|
||
again
|
||
.changes
|
||
.iter()
|
||
.filter(|c| c.kind == "route" && c.action == "update")
|
||
.count()
|
||
>= 2,
|
||
"Changed routing precedence must be visible in the preview"
|
||
);
|
||
assert_eq!(
|
||
old_ids,
|
||
again.document.schedules.iter().map(|s| s.id).collect()
|
||
);
|
||
let id = e
|
||
.doc
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Payments · owners")
|
||
.unwrap()
|
||
.id;
|
||
let from = engine::handover(&e.doc, e.day + Duration::days(1)).unwrap();
|
||
e.doc.requests.push(ReplacementRequest {
|
||
effective_legs: None,
|
||
id: Uuid::new_v4(),
|
||
absence_id: None,
|
||
requested_by: e.people[0],
|
||
reason: "Cover".into(),
|
||
applied_at: Some(from),
|
||
forced_by: Some(e.people[0]),
|
||
created_at: from - Duration::days(1),
|
||
legs: vec![ReplacementLeg {
|
||
schedule_id: id,
|
||
start: from,
|
||
end: from + Duration::hours(1),
|
||
from_user: e.people[0],
|
||
to_user: e.people[1],
|
||
}],
|
||
status: RequestStatus::Applied,
|
||
required_approvers: BTreeSet::new(),
|
||
approvals: BTreeSet::new(),
|
||
force_reason: Some("Cover".into()),
|
||
});
|
||
e.variables[0]
|
||
.node
|
||
.entries
|
||
.retain(|n| n.value != "Payments v2");
|
||
assert!(
|
||
e.expand()
|
||
.issues
|
||
.iter()
|
||
.any(|i| i.message.contains("approved replacements"))
|
||
);
|
||
e.doc.requests[0].status = RequestStatus::Pending;
|
||
let out = e.expand();
|
||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||
assert_eq!(out.document.requests[0].status, RequestStatus::Cancelled);
|
||
let mut manual = e.doc.clone();
|
||
manual.schedules[0].name = "Edited directly".into();
|
||
assert!(generation::validate_manual_edit(&e.doc, &mut manual).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn bad_dictionary_values_types_scope_cycles_and_bounds_have_actionable_errors() {
|
||
let mut e = Example::new();
|
||
e.variables[1].node.entries.retain(|n| n.key != "Search");
|
||
let out = e.expand();
|
||
assert!(
|
||
out.issues
|
||
.iter()
|
||
.any(|i| i.path.contains("Search") && i.message.contains("key ‘Search’"))
|
||
);
|
||
let mut e = Example::new();
|
||
e.variables[1]
|
||
.node
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "Payments")
|
||
.unwrap()
|
||
.entries
|
||
.clear();
|
||
assert!(
|
||
e.expand()
|
||
.issues
|
||
.iter()
|
||
.any(|i| i.message.contains("1–100"))
|
||
);
|
||
let mut e = Example::new();
|
||
e.rules[1].blocks[0].fields.insert(
|
||
"participants".into(),
|
||
Binding {
|
||
kind: "literal".into(),
|
||
value: json!("wrong type"),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(e.expand().issues.iter().any(|i| i.message.contains("list")));
|
||
let mut e = Example::new();
|
||
let scoped = e.rules[1].blocks[0].id;
|
||
e.rules[0].blocks[1].steps[0].recipient.output = Some(scoped);
|
||
assert!(
|
||
e.expand()
|
||
.issues
|
||
.iter()
|
||
.any(|i| i.message.contains("output block"))
|
||
);
|
||
let mut e = Example::new();
|
||
let own = e.rules[1].blocks[0].id;
|
||
e.rules[1].blocks[0]
|
||
.fields
|
||
.get_mut("fallback")
|
||
.unwrap()
|
||
.output = Some(own);
|
||
assert!(
|
||
e.expand()
|
||
.issues
|
||
.iter()
|
||
.any(|i| i.message.contains("cycle"))
|
||
);
|
||
let mut e = Example::new();
|
||
for _ in 0..5 {
|
||
let mut i = e.rules[1].iterations[0].clone();
|
||
i.id = Uuid::new_v4();
|
||
e.rules[1].iterations.push(i);
|
||
}
|
||
assert!(
|
||
e.expand()
|
||
.issues
|
||
.iter()
|
||
.any(|i| i.message.contains("at most 4"))
|
||
);
|
||
let mut e = Example::new();
|
||
let duplicate = e.variables[1].node.entries[0].clone();
|
||
e.variables[1].node.entries.push(duplicate);
|
||
assert!(!generation::validate_variables(&e.variables).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_projects_expand_deterministically_and_tenants_do_not_share_ids() {
|
||
let mut e = Example::new();
|
||
let project = e.variables[0].node.entries[0].clone();
|
||
let owners = e.variables[1].node.entries[0].clone();
|
||
e.variables[0].node.entries.clear();
|
||
e.variables[1].node.entries.clear();
|
||
for i in 0..30 {
|
||
let name = format!("Project {i}");
|
||
let mut p = project.clone();
|
||
p.id = Uuid::new_v4();
|
||
p.value = json!(name);
|
||
e.variables[0].node.entries.push(p);
|
||
let mut o = owners.clone();
|
||
o.id = Uuid::new_v4();
|
||
o.key = name;
|
||
for n in &mut o.entries {
|
||
n.id = Uuid::new_v4();
|
||
}
|
||
e.variables[1].node.entries.push(o);
|
||
}
|
||
let first = e.expand();
|
||
assert!(first.issues.is_empty(), "{:?}", first.issues);
|
||
assert_eq!(first.document.schedules.len(), 31);
|
||
assert_eq!(
|
||
serde_json::to_value(&first.document).unwrap(),
|
||
serde_json::to_value(e.expand().document).unwrap()
|
||
);
|
||
e.team = Uuid::new_v4();
|
||
let other = e.expand();
|
||
assert!(
|
||
other.document.schedules.iter().all(|s| !first
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.any(|old| old.id == s.id))
|
||
);
|
||
let (variables, rules) = demo::support_queues(e.day, e.people[0], e.people[1]);
|
||
let support = generation::expand(
|
||
&e.doc,
|
||
e.team,
|
||
&variables,
|
||
&rules,
|
||
0,
|
||
&WorkingWindow::default(),
|
||
&e.people.into_iter().collect(),
|
||
Utc::now(),
|
||
);
|
||
assert!(support.issues.is_empty(), "{:?}", support.issues);
|
||
assert_eq!(support.document.escalations[0].steps[1].delay_minutes, 15);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_two_owner_rules_keep_dynamic_bindings_and_resource_identity() {
|
||
let mut example = Example::new();
|
||
for variable in &mut example.variables {
|
||
if variable.name == "responsible" {
|
||
for entry in &mut variable.node.entries {
|
||
entry.entries.truncate(2);
|
||
}
|
||
}
|
||
}
|
||
let expected = example.expand();
|
||
let mut rules = serde_json::to_value(&example.rules).unwrap();
|
||
for rule in rules.as_array_mut().unwrap() {
|
||
for block in rule["blocks"].as_array_mut().unwrap() {
|
||
if block["kind"] == "priority" {
|
||
block["kind"] = json!("coverage");
|
||
let fields = block["fields"].as_object_mut().unwrap();
|
||
let mut first = fields.remove("participants").unwrap();
|
||
first["path"] = json!(["1"]);
|
||
let mut second = first.clone();
|
||
second["path"] = json!(["2"]);
|
||
fields.insert("primary".into(), first);
|
||
fields.insert("secondary".into(), second);
|
||
}
|
||
}
|
||
}
|
||
example.rules = serde_json::from_value(rules).unwrap();
|
||
let actual = example.expand();
|
||
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
||
assert_eq!(
|
||
serde_json::to_value(&expected.document.schedules).unwrap(),
|
||
serde_json::to_value(&actual.document.schedules).unwrap()
|
||
);
|
||
assert_eq!(
|
||
serde_json::to_value(&expected.document.routes).unwrap(),
|
||
serde_json::to_value(&actual.document.routes).unwrap()
|
||
);
|
||
assert!(
|
||
example
|
||
.rules
|
||
.iter()
|
||
.flat_map(|r| &r.blocks)
|
||
.all(|b| b.kind != "coverage")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_schedule_deserializes_to_priority_without_changing_references() {
|
||
let id = Uuid::new_v4();
|
||
let primary = Uuid::new_v4();
|
||
let secondary = Uuid::new_v4();
|
||
let fallback = Uuid::new_v4();
|
||
let schedule: Schedule = serde_json::from_value(json!({"id":id,"name":"Legacy","handover":"09:30:00","type":"coverage","primary":primary,"secondary":secondary,"fallback":fallback})).unwrap();
|
||
let value = serde_json::to_value(&schedule).unwrap();
|
||
assert_eq!(value["type"], "priority");
|
||
assert_eq!(value["participants"], json!([primary, secondary]));
|
||
assert_eq!(value["fallback"], json!(fallback));
|
||
assert_eq!(value["id"], json!(id));
|
||
assert_eq!(value["handover"], "09:30:00");
|
||
assert!(value.get("primary").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_records_normalize_recursively_and_preserve_generated_resources() {
|
||
let mut example = Example::new();
|
||
let expected = example.expand();
|
||
let original = serde_json::to_value(&example.variables).unwrap();
|
||
let legacy = serde_json::to_string(&example.variables)
|
||
.unwrap()
|
||
.replace("\"kind\":\"dictionary\"", "\"kind\":\"record\"");
|
||
example.variables = serde_json::from_str(&legacy).unwrap();
|
||
assert_eq!(serde_json::to_value(&example.variables).unwrap(), original);
|
||
let actual = example.expand();
|
||
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
||
assert_eq!(
|
||
serde_json::to_value(&actual.document.schedules).unwrap(),
|
||
serde_json::to_value(&expected.document.schedules).unwrap()
|
||
);
|
||
let id = Uuid::new_v4();
|
||
let nested: generation::Node = serde_json::from_value(json!({"id":id,"kind":"record","entries":[{"id":Uuid::new_v4(),"kind":"record","key":"settings","entries":[{"id":Uuid::new_v4(),"kind":"text","key":"lead","value":"alice@example.com"}]}]})).unwrap();
|
||
assert_eq!(nested.id, id);
|
||
assert_eq!(nested.kind, "dictionary");
|
||
assert_eq!(nested.entries[0].kind, "dictionary");
|
||
assert_eq!(nested.entries[0].entries[0].value, "alice@example.com");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_demos_expand_nested_portfolios_and_tier_lookups() {
|
||
let mut e = Example::new();
|
||
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
|
||
let expanded = e.expand();
|
||
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
||
assert_eq!(expanded.document.schedules.len(), 4);
|
||
assert_eq!(expanded.document.routes.len(), 3);
|
||
assert!(
|
||
!expanded
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.any(|s| s.name.contains("Sandbox"))
|
||
);
|
||
let trace = &expanded.trace[1];
|
||
assert_eq!(trace["stages"][0]["count"], 2);
|
||
let singleton = &expanded.trace[0]["rows"][0]["blocks"][0];
|
||
assert_eq!(singleton["fields"]["participants"], json!([e.people[0]]));
|
||
assert_eq!(singleton["fields"]["name"], "Incident commander");
|
||
assert!(singleton["fields"]["participants"].is_array());
|
||
assert_eq!(trace["stages"][1]["count"], 4);
|
||
let rows = trace["rows"].as_array().unwrap();
|
||
assert_eq!(rows.len(), 4);
|
||
assert_eq!(rows.iter().filter(|r| r["status"] == "filtered").count(), 1);
|
||
assert!(
|
||
rows.iter()
|
||
.filter(|r| r["status"] == "filtered")
|
||
.all(|r| r["resources"].as_array().unwrap().is_empty())
|
||
);
|
||
let produced: BTreeSet<Uuid> = rows
|
||
.iter()
|
||
.flat_map(|r| r["resources"].as_array().unwrap())
|
||
.map(|id| serde_json::from_value(id.clone()).unwrap())
|
||
.collect();
|
||
assert_eq!(
|
||
produced,
|
||
expanded
|
||
.document
|
||
.generation
|
||
.managed
|
||
.iter()
|
||
.filter(|m| m.rule == e.rules[1].id)
|
||
.map(|m| m.id)
|
||
.collect()
|
||
);
|
||
assert!(
|
||
rows.iter()
|
||
.any(|r| r["inputs"].to_string().contains("Pacific · Sandbox"))
|
||
);
|
||
let pacific = expanded
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.find(|s| s.name == "Pacific · Gateway")
|
||
.unwrap();
|
||
assert_eq!(pacific.handover.to_string(), "05:00:00");
|
||
assert!(
|
||
matches!(&pacific.kind, ScheduleKind::Rotation {shift_days:2, participants,..} if participants[0] == e.people[1])
|
||
);
|
||
assert!(
|
||
expanded
|
||
.document
|
||
.escalations
|
||
.iter()
|
||
.all(|p| p.steps.len() == 2)
|
||
);
|
||
(e.variables, e.rules) = demo::service_tiers(e.day, e.people[0], e.people[1]);
|
||
let expanded = e.expand();
|
||
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
||
assert_eq!(expanded.document.routes.len(), 4);
|
||
assert!(
|
||
!expanded
|
||
.document
|
||
.schedules
|
||
.iter()
|
||
.any(|s| s.name == "Legacy export")
|
||
);
|
||
let checkout = expanded
|
||
.document
|
||
.escalations
|
||
.iter()
|
||
.find(|p| p.name == "Checkout")
|
||
.unwrap();
|
||
assert_eq!(checkout.steps[1].delay_minutes, 5);
|
||
assert_eq!(checkout.repeat.count, 2);
|
||
assert_eq!(checkout.repeat.wait_minutes, 10);
|
||
assert!(
|
||
expanded
|
||
.document
|
||
.routes
|
||
.iter()
|
||
.find(|r| r.name == "Checkout")
|
||
.unwrap()
|
||
.window
|
||
.is_none()
|
||
);
|
||
assert!(
|
||
expanded
|
||
.document
|
||
.routes
|
||
.iter()
|
||
.find(|r| r.name == "Analytics")
|
||
.unwrap()
|
||
.window
|
||
.is_some()
|
||
);
|
||
// One tier edit changes every referencing product without replacing resource identity.
|
||
let tiers = e
|
||
.variables
|
||
.iter_mut()
|
||
.find(|v| v.name == "service_tiers")
|
||
.unwrap();
|
||
let standard = tiers
|
||
.node
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "standard")
|
||
.unwrap();
|
||
standard
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "delay")
|
||
.unwrap()
|
||
.value = json!(45);
|
||
let changed = e.expand();
|
||
assert!(changed.issues.is_empty(), "{:?}", changed.issues);
|
||
for name in ["Analytics", "Merchant portal"] {
|
||
let before = expanded
|
||
.document
|
||
.escalations
|
||
.iter()
|
||
.find(|p| p.name == name)
|
||
.unwrap();
|
||
let after = changed
|
||
.document
|
||
.escalations
|
||
.iter()
|
||
.find(|p| p.name == name)
|
||
.unwrap();
|
||
assert_eq!(before.id, after.id);
|
||
assert_eq!(after.steps[1].delay_minutes, 45);
|
||
}
|
||
// A disabled entry need not resolve its missing retired tier until enabled.
|
||
let products = e
|
||
.variables
|
||
.iter_mut()
|
||
.find(|v| v.name == "products")
|
||
.unwrap();
|
||
products
|
||
.node
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "Legacy export")
|
||
.unwrap()
|
||
.entries
|
||
.iter_mut()
|
||
.find(|n| n.key == "enabled")
|
||
.unwrap()
|
||
.value = json!(true);
|
||
assert!(!e.expand().issues.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn participant_error_has_source_ids_and_marks_cascading_configuration_error() {
|
||
let mut e = Example::new();
|
||
let variable = e
|
||
.variables
|
||
.iter_mut()
|
||
.find(|v| v.name == "on_call")
|
||
.unwrap();
|
||
variable.node.entries[0].kind = "date".into();
|
||
variable.node.entries[0].value = json!("2026-09-24");
|
||
let out = e.expand();
|
||
let source = out
|
||
.issues
|
||
.iter()
|
||
.find(|i| i.message.contains("not an email"))
|
||
.unwrap();
|
||
assert_eq!(source.rule_id, Some(e.rules[0].id));
|
||
assert_eq!(source.block_id, Some(e.rules[0].blocks[0].id));
|
||
assert!(out.issues.iter().any(|i| i.secondary));
|
||
}
|
||
|
||
#[test]
|
||
fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
|
||
assert!(
|
||
serde_json::from_value::<meguri::domain::ScheduleKind>(json!({
|
||
"type": "backup", "user": uuid::Uuid::new_v4()
|
||
}))
|
||
.is_err()
|
||
);
|
||
let mut example = Example::new();
|
||
example.rules[0].blocks[0].kind = "backup".into();
|
||
assert!(!example.expand().issues.is_empty());
|
||
}
|