Files
meguri/tests/generation.rs
T
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
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>
2026-10-07 13:28:43 +01:00

693 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}