#[path = "support/fake_jsm.rs"] mod fake_jsm; mod support; use chrono::{Duration, Utc}; use cot::db::query; use meguri::{ domain::*, engine, error::Error, models::{AuditRow, HistoryRow, SyncJobRow}, providers, service, worker, }; use serde_json::json; use support::Fixture; use uuid::Uuid; #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn generation_is_workspace_scoped_atomic_and_revision_checked() { use meguri::{ demo, generation_service::{self, Draft}, }; let mut f = Fixture::new().await; let actor = f.people[0].id; let id = f.team.id; let (variables, rules) = demo::project_matrix(Utc::now().date_naive(), actor, f.people[1].id); let mut draft = Draft { shared_revision: None, revision: f.team.revision, variables, rules, route_position: 0, review_id: None, }; let preview = generation_service::preview(&f.db, actor, id, draft.clone()) .await .unwrap(); assert_eq!(preview["issues"], json!([])); assert_eq!(f.db.workspace(id).await.unwrap().revision, draft.revision); for kind in ["rotation", "priority", "escalation", "route"] { assert!( preview["result"]["managed"] .as_array() .unwrap() .iter() .any(|m| m["kind"] == kind) ); } assert!( generation_service::apply(&f.db, actor, id, draft.clone()) .await .is_err() ); draft.review_id = preview["review_id"].as_str().map(str::to_owned); let other = f .extra_workspace("Independent workspace", &f.people[0]) .await; assert!( generation_service::preview(&f.db, f.people[1].id, other.id, draft.clone()) .await .is_err() ); let (a, b) = tokio::join!( generation_service::apply(&f.db, actor, id, draft.clone()), generation_service::apply(&f.db, actor, id, draft.clone()) ); assert_ne!(a.is_ok(), b.is_ok()); f.team = f.db.workspace(id).await.unwrap(); assert_eq!(f.team.revision, draft.revision + 1); assert_eq!(f.team.document.generation.variables.len(), 3); assert_eq!( f.db.workspace(other.id).await.unwrap().revision, other.revision ); assert!( f.db.workspace(other.id) .await .unwrap() .document .generation .variables .is_empty() ); assert_eq!( f.db.sync_status(id).await.unwrap()["requested_revision"], f.team.revision ); assert!( f.db.audit(id) .await .unwrap() .iter() .any(|e| e["action"] == "generation.applied") ); let mut stale = draft; stale.revision = f.team.revision; stale.rules[0].name = "Changed after review".into(); assert!( generation_service::apply(&f.db, actor, id, stale) .await .is_err() ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn absence_preview_is_read_only_and_bulk_ranges_need_the_right_confirmations() { use meguri::absence::{CoverRange, Draft}; let mut f = Fixture::new().await; let owner = f.people[0].id; let absent = f.people[1].id; let second = f.people[2].id; let team = f.team.id; let mut doc = f.team.document.clone(); if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { *participants = vec![absent]; } doc.schedules.push(Schedule { id: Uuid::new_v4(), name: "Backup".into(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![absent], anchor: "2026-09-21".parse().unwrap(), shift_days: 7, }, }); doc.schedules.push(Schedule { id: Uuid::new_v4(), name: "Project".into(), handover: default_handover(), kind: ScheduleKind::Priority { participants: vec![absent, second], fallback: f.schedule, }, }); f.team = service::update_document(&f.db, owner, team, f.team.revision, doc) .await .unwrap(); let start = engine::schedule_duty_day(&f.team.document, f.schedule, f.start).unwrap(); let draft = Draft { absence: Absence { id: Uuid::new_v4(), user_id: absent, start, end: start + Duration::days(13), }, ranges: vec![ CoverRange { local: None, exact: None, start, end: start + Duration::days(6), replacement: owner, replace_projects: false, }, CoverRange { local: None, exact: None, start: start + Duration::days(7), end: start + Duration::days(13), replacement: second, replace_projects: false, }, ], }; let revision = f.team.revision; let input = |force| service::NewAbsence { revision, draft: draft.clone(), people: vec![], force, reason: "Vacation".into(), }; let preview = service::preview_absence(&f.db, absent, team, input(false)) .await .unwrap(); assert_eq!(preview["plan"]["schedules"], 3); assert_eq!(preview["plan"]["uncovered"], 0); assert_eq!(f.db.workspace(team).await.unwrap().revision, revision); assert!( service::create_absence(&f.db, absent, team, input(true)) .await .is_err() ); assert!( service::preview_absence(&f.db, absent, Uuid::new_v4(), input(false)) .await .is_err() ); f.team = service::create_absence(&f.db, absent, team, input(false)) .await .unwrap(); assert_eq!(f.team.revision, revision + 1); assert_eq!(f.team.document.requests.len(), 2); assert!( f.team .document .requests .iter() .all(|r| r.status == RequestStatus::Pending && r.legs.len() == 2) ); assert_eq!( f.db.audit(team).await.unwrap()[0]["action"], "absence.created" ); assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], f.team.revision ); assert!(matches!( service::create_absence(&f.db, absent, team, input(false)).await, Err(Error::Conflict(_)) )); let end = engine::schedule_handover(&f.team.document, f.schedule, start + Duration::days(14)) .unwrap(); let timeline = engine::timeline(&f.team.document, f.start, end).unwrap(); assert!( providers::compile(&f.team, timeline, Utc::now()).is_err(), "Pending coverage cannot publish an absent backup or rotation" ); let first = f.team.document.requests[0].id; assert!( service::decide_request(&f.db, second, team, first, f.decision("approve")) .await .is_err() ); f.team = service::decide_request(&f.db, owner, team, first, f.decision("approve")) .await .unwrap(); let next = f.team.document.requests[1].id; f.team = service::decide_request(&f.db, second, team, next, f.decision("approve")) .await .unwrap(); let timeline = engine::timeline(&f.team.document, f.start, end).unwrap(); assert!( timeline .assignments .iter() .all(|a| !a.unavailable && a.user_id != absent) ); assert!(providers::compile(&f.team, timeline, Utc::now()).is_ok()); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn schedule_removal_rewrites_dependencies_and_cancels_pending_requests_atomically() { let mut f = Fixture::new().await; let actor = f.people[0].id; let team = f.team.id; let secondary = Uuid::new_v4(); let mut doc = f.team.document.clone(); doc.schedules.push(Schedule { id: secondary, name: "Payments secondary".into(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![actor], anchor: "2026-09-21".parse().unwrap(), shift_days: 7, }, }); doc.escalations.push(Escalation { id: Uuid::new_v4(), name: "Payments escalation".into(), steps: [f.schedule, secondary] .into_iter() .enumerate() .map(|(i, id)| EscalationStep { delay_minutes: (i * 10) as u16, condition: EscalationCondition::IfNotAcked, recipient: Recipient::Schedule { id }, }) .collect(), repeat: Default::default(), }); f.team = service::update_document(&f.db, actor, team, f.team.revision, doc) .await .unwrap(); let mut leg = f.leg(); leg.schedule_id = secondary; f.propose(vec![leg], false).await; let revision = f.team.revision; let input = || service::RemoveSchedule { revision, redirect_to: None, }; assert!(matches!( service::remove_schedule(&f.db, f.people[1].id, team, secondary, input()).await, Err(Error::Forbidden) )); assert!( service::remove_schedule(&f.db, actor, Uuid::new_v4(), secondary, input()) .await .is_err() ); assert!(matches!( service::remove_schedule(&f.db, actor, team, Uuid::new_v4(), input()).await, Err(Error::NotFound) )); let before = f.db.audit(team).await.unwrap().len(); f.team = service::remove_schedule(&f.db, actor, team, secondary, input()) .await .unwrap(); assert_eq!(f.team.revision, revision + 1); assert!(f.team.document.schedule(secondary).is_none()); assert_eq!(f.team.document.escalations[0].steps.len(), 1); assert!( matches!(f.team.document.escalations[0].steps[0].recipient, Recipient::Schedule { id } if id == f.schedule) ); assert_eq!(f.team.document.requests[0].status, RequestStatus::Cancelled); let events = f.db.audit(team).await.unwrap(); assert_eq!(events.len(), before + 1); assert_eq!(events[0]["action"], "schedule.removed"); assert_eq!( events[0]["details"]["impact"]["pending_requests"][0], f.team.document.requests[0].id.to_string() ); assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], f.team.revision ); let key = team.to_string(); let history = query!(HistoryRow, $workspace_id == key) .all(&f.db.db) .await .unwrap(); let old: WorkspaceDocument = serde_json::from_str( &history .iter() .find(|h| h.revision == revision) .unwrap() .document, ) .unwrap(); assert!(old.schedule(secondary).is_some()); assert_eq!(old.requests[0].status, RequestStatus::Pending); assert!(matches!( service::remove_schedule(&f.db, actor, team, f.schedule, input()).await, Err(Error::Conflict(_)) )); let unchanged = f.team.revision; assert!( service::remove_schedule( &f.db, actor, team, f.schedule, service::RemoveSchedule { revision: unchanged, redirect_to: None } ) .await .is_err(), "The last escalation step must not disappear" ); assert_eq!(f.db.workspace(team).await.unwrap().revision, unchanged); f.propose(vec![f.leg()], true).await; let protected = f.team.revision; assert!( service::remove_schedule( &f.db, actor, team, f.schedule, service::RemoveSchedule { revision: protected, redirect_to: None } ) .await .is_err(), "Approved future replacements protect the schedule" ); assert_eq!(f.db.workspace(team).await.unwrap().revision, protected); assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], protected ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn routing_order_is_scoped_revision_checked_and_published_as_one_change() { let mut f = Fixture::new().await; let actor = f.people[0].id; let team = f.team.id; let ids = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()]; let mut doc = f.team.document.clone(); doc.routes = ids .iter() .zip([0, 10, 9999]) .map(|(id, order)| RoutingRule { id: *id, name: format!("Rule {order}"), order, conditions: vec![RouteCondition { field: "tags".into(), operation: "contains".into(), value: id.to_string(), negate: false, }], window: None, escalation_id: None, }) .collect(); f.team = service::update_document(&f.db, actor, team, f.team.revision, doc) .await .unwrap(); let revision = f.team.revision; let input = |ids: Vec| service::ReorderRoutes { revision, ids }; assert!(matches!( service::reorder_routes(&f.db, f.people[1].id, team, input(ids.to_vec())).await, Err(Error::Forbidden) )); assert!( service::reorder_routes(&f.db, actor, Uuid::new_v4(), input(ids.to_vec())) .await .is_err() ); let before = f.db.audit(team).await.unwrap().len(); for invalid in [ vec![ids[0]], vec![ids[0], ids[0], ids[2]], vec![ids[0], ids[1], Uuid::new_v4()], ] { assert!( service::reorder_routes(&f.db, actor, team, input(invalid)) .await .is_err() ); } assert_eq!(f.db.workspace(team).await.unwrap().revision, revision); assert_eq!(f.db.audit(team).await.unwrap().len(), before); let wanted = vec![ids[2], ids[0], ids[1]]; f.team = service::reorder_routes(&f.db, actor, team, input(wanted.clone())) .await .unwrap(); assert_eq!( f.team .document .routes .iter() .map(|r| r.id) .collect::>(), wanted ); assert_eq!( f.team .document .routes .iter() .map(|r| r.order) .collect::>(), vec![0, 1, 2] ); for route in &f.team.document.routes { assert_eq!(route.conditions[0].value, route.id.to_string()); } assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], revision + 1 ); let events = f.db.audit(team).await.unwrap(); assert_eq!(events.len(), before + 1); assert_eq!(events[0]["action"], "routing.reordered"); assert_eq!( events[0]["details"], json!({"before": ids, "after": wanted}) ); assert!(matches!( service::reorder_routes(&f.db, actor, team, input(ids.to_vec())).await, Err(Error::Conflict(_)) )); let current = f.team.revision; let (left, right) = tokio::join!( service::reorder_routes( &f.db, actor, team, service::ReorderRoutes { revision: current, ids: ids.to_vec() } ), service::reorder_routes( &f.db, actor, team, service::ReorderRoutes { revision: current, ids: vec![ids[1], ids[2], ids[0]] } ) ); assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1); let key = team.to_string(); let history = query!(HistoryRow, $workspace_id == key) .all(&f.db.db) .await .unwrap(); assert_eq!(history.len() as i64, current + 1); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn email_contacts_are_scoped_people_without_login_or_access_and_import_is_atomic() { let mut f = Fixture::new().await; let other = Fixture::new().await; let actor = f.people[0].id; let tenant = f.team.id; let team = f.team.id; assert!( !f.db .directory(tenant) .await .unwrap() .iter() .any(|u| u.id == other.people[0].id) ); let id = Uuid::new_v4(); let mut doc = f.team.document.clone(); doc.people.push(Person { id, user_id: None, display_name: "New colleague".into(), email: Some(" New.Person@Example.com ".into()), country: Some("am".into()), aliases: vec![], }); if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { participants.push(id); } doc.absences.push(Absence { id: Uuid::new_v4(), user_id: id, start: f.start.date_naive() + Duration::days(100), end: f.start.date_naive() + Duration::days(101), }); f.team = service::update_document(&f.db, actor, team, f.team.revision, doc) .await .unwrap(); let contact = f.team.document.person(id).unwrap(); assert_eq!(contact.email.as_deref(), Some("new.person@example.com")); assert_eq!(contact.country.as_deref(), Some("AM")); let key = id.to_string(); assert!( !query!(meguri::models::UserRow,$id==key) .exists(&f.db.db) .await .unwrap() ); assert!(f.db.require(team, id, Permission::View).await.is_err()); let mut forged = f.team.document.clone(); forged.people[0].user_id = Some(other.people[0].id); assert!( service::update_document(&f.db, actor, team, f.team.revision, forged) .await .is_err() ); let mut duplicate = f.team.document.clone(); let mut person = duplicate.people[0].clone(); person.id = Uuid::new_v4(); duplicate.people.push(person); assert!( service::update_document(&f.db, actor, team, f.team.revision, duplicate) .await .is_err() ); let csv = "country,date,name\nCY,2027-01-01,New Year\nAM,2027-01-01,New Year"; let version = f.team.revision; let preview = meguri::holidays::compare(csv, &f.team.document.holidays).unwrap(); assert_eq!(preview.additions.len(), 2); assert_eq!( f.db.workspace(team).await.unwrap().revision, version, "Preview is pure" ); assert!( service::import_holidays(&f.db, f.people[1].id, team, version, csv) .await .is_err() ); assert!( service::import_holidays( &f.db, actor, team, version, "country,date,name\nXX,2027-01-01,Invalid\nCY,2027-01-02,Valid" ) .await .is_err() ); assert_eq!( f.db.workspace(team).await.unwrap().revision, version, "Invalid CSV writes nothing" ); f.team = service::import_holidays(&f.db, actor, team, version, csv) .await .unwrap(); assert_eq!(f.team.document.holidays.len(), 2); assert!(matches!( service::import_holidays( &f.db, actor, team, version, "country,date,name\nCY,2027-12-25,Christmas" ) .await, Err(Error::Conflict(_)) )); f.team = service::import_holidays( &f.db, actor, team, f.team.revision, "country,date,name\nCY,2027-01-01,Changed name\nCY,2027-12-25,Christmas", ) .await .unwrap(); assert_eq!(f.team.document.holidays[0].name, "New Year"); assert_eq!(f.team.document.holidays.len(), 3); let audit = f.db.audit(team).await.unwrap(); assert!(audit.iter().any(|e| e["action"] == "holidays.imported")); let job = f.db.sync_status(team).await.unwrap(); assert_eq!(job["requested_revision"], f.team.revision); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn workspace_interval_propagates_atomically_and_user_timezone_does_not_move_shifts() { let mut f = Fixture::new().await; let actor = f.people[0].id; let tenant = f.team.id; let team = f.team.id; let other = f.extra_workspace("Another team", &f.people[0]).await; let mut doc = f.team.document.clone(); doc.routes.push(RoutingRule { id: Uuid::new_v4(), name: "Working only".into(), order: 1, conditions: vec![], window: Some(WorkingWindow::default()), escalation_id: None, }); f.team = service::update_document(&f.db, actor, team, f.team.revision, doc) .await .unwrap(); let revision = f.team.revision; let settings_revision = f.team.revision; let interval = WorkingWindow { timezone: "Asia/Yerevan".into(), weekdays: vec![1, 3, 5], start: "09:00:00".parse().unwrap(), end: "18:00:00".parse().unwrap(), }; assert!( service::set_working_interval( &f.db, f.people[1].id, tenant, settings_revision, interval.clone() ) .await .is_err() ); service::set_working_interval(&f.db, actor, team, settings_revision, interval.clone()) .await .unwrap(); assert!(matches!( service::set_working_interval(&f.db, actor, team, settings_revision, interval.clone()) .await, Err(Error::Conflict(_)) )); f.team = f.db.workspace(team).await.unwrap(); assert_eq!(f.team.revision, revision + 1); assert_eq!(f.team.document.timezone, "Asia/Yerevan"); assert_eq!(f.team.document.routes[0].window.as_ref(), Some(&interval)); assert_eq!( f.db.workspace(other.id).await.unwrap().revision, other.revision ); assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], f.team.revision ); let before = serde_json::to_value( engine::timeline(&f.team.document, f.start, f.start + Duration::days(30)).unwrap(), ) .unwrap(); let key = actor.to_string(); let mut user = query!(meguri::models::UserRow,$id==key.clone()) .get(&f.db.db) .await .unwrap() .unwrap(); user.timezone = Some("America/New_York".into()); user.language = Some("fr".into()); service::save_profile(&f.db, &user).await.unwrap(); let unchanged = f.db.workspace(team).await.unwrap(); assert_eq!( query!(meguri::models::UserRow,$id==key) .get(&f.db.db) .await .unwrap() .unwrap() .language .as_deref(), Some("fr") ); assert_eq!(unchanged.revision, f.team.revision); assert_eq!( serde_json::to_value( engine::timeline(&unchanged.document, f.start, f.start + Duration::days(30)).unwrap() ) .unwrap(), before ); user.country = "AM".into(); service::save_profile(&f.db, &user).await.unwrap(); let changed = f.db.workspace(team).await.unwrap(); assert_eq!(changed.revision, f.team.revision + 1); assert_eq!( changed.document.person(actor).unwrap().country.as_deref(), Some("AM") ); assert_eq!(changed.document.timezone, "Asia/Yerevan"); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn workspace_settings_and_configuration_share_one_revision() { let f = Fixture::new().await; let actor = f.people[0].id; let id = f.team.id; let a = WorkingWindow { timezone: "Asia/Yerevan".into(), ..WorkingWindow::default() }; let b = WorkingWindow { timezone: "Asia/Tokyo".into(), ..WorkingWindow::default() }; let (first, second) = tokio::join!( service::set_working_interval(&f.db, actor, id, f.team.revision, a), service::set_working_interval(&f.db, actor, id, f.team.revision, b) ); assert_ne!(first.is_ok(), second.is_ok()); let current = f.db.workspace(id).await.unwrap(); assert_eq!(current.revision, f.team.revision + 1); assert_eq!( current.document.timezone, current.document.working_interval.timezone ); assert!( service::update_document(&f.db, actor, id, f.team.revision, f.team.document.clone()) .await .is_err() ); assert!( service::set_working_interval( &f.db, f.people[1].id, id, current.revision, WorkingWindow::default() ) .await .is_err() ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_resolves_exact_active_email_before_writes_and_explicit_ids_override_lookup() { let mut f = Fixture::new().await; let server = fake_jsm::Server::start().await; let contact = Uuid::new_v4(); f.team.document.people.push(Person { id: contact, user_id: None, display_name: "Email contact".into(), email: Some("new@example.com".into()), country: Some("CY".into()), aliases: vec![], }); f.team.document.schedules[0].kind = ScheduleKind::Rotation { participants: vec![contact], anchor: f.start.date_naive(), shift_days: 7, }; f.team.document.integration = Integration::Jsm { cloud_id: Uuid::new_v4().to_string(), team_id: Uuid::new_v4().to_string(), advanced_escalations: false, users: Default::default(), }; let plan = providers::compile( &f.team, engine::timeline(&f.team.document, f.start, f.start + Duration::days(7)).unwrap(), f.start, ) .unwrap(); let client = providers::jsm::Jsm::new( server.base.clone(), "admin@example.com".into(), "fake".into(), Uuid::new_v4().to_string(), Default::default(), ) .unwrap() .with_directory_base(server.base.clone()); for users in [ vec![], vec![json!({"active":true,"accountId":"hidden"})], vec![json!({"active":false,"emailAddress":"new@example.com","accountId":"inactive"})], vec![json!({"active":true,"emailAddress":"wrong@example.com","accountId":"wrong"})], vec![ json!({"active":true,"emailAddress":"new@example.com","accountId":"a"}), json!({"active":true,"emailAddress":"new@example.com","accountId":"b"}), ], ] { server.state.lock().unwrap().users = users; assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); assert_eq!( server.state.lock().unwrap().writes, 0, "No remote writes before identity is unambiguous" ); } server.state.lock().unwrap().users = vec![json!({"active":true,"emailAddress":"NEW@example.com","accountId":"resolved-id"})]; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); let schedule = state.objects.values().next().unwrap(); assert_eq!( schedule["rotations"][0]["participants"][0]["id"], "resolved-id" ); } let lookups = server.state.lock().unwrap().lookups; let manual = providers::jsm::Jsm::new( server.base.clone(), "admin@example.com".into(), "fake".into(), Uuid::new_v4().to_string(), [(contact, "resolved-id".into())].into(), ) .unwrap(); manual.publish(&f.db, &f.team, &plan, true).await.unwrap(); assert_eq!(server.state.lock().unwrap().lookups, lookups); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn approval_swap_tenant_isolation_and_atomic_outbox() { let mut f = Fixture::new().await; let owner = f.people[0].id; let bob = f.people[1].id; let carol = f.people[2].id; assert!(f.db.workspace(Uuid::new_v4()).await.is_err()); assert!( f.db.require(f.team.id, Uuid::new_v4(), Permission::View) .await .is_err() ); let other = f.extra_workspace("Private workspace", &f.people[0]).await; assert!(f.db.require(other.id, bob, Permission::View).await.is_err()); assert!(matches!( service::update_document( &f.db, bob, f.team.id, f.team.revision, f.team.document.clone() ) .await, Err(Error::Forbidden) )); assert!( service::set_drift_policy(&f.db, bob, f.team.id, f.team.revision, true) .await .is_err() ); let leg = f.leg(); let back = ReplacementLeg { start: leg.start + Duration::days(7), end: leg.end + Duration::days(7), from_user: bob, to_user: owner, ..leg.clone() }; f.propose(vec![leg.clone(), back.clone()], false).await; let request = f.team.document.requests.last().unwrap().id; assert_eq!( engine::timeline(&f.team.document, leg.start, leg.end) .unwrap() .assignments[0] .user_id, owner ); assert!( service::decide_request(&f.db, owner, f.team.id, request, f.decision("approve")) .await .is_err() ); assert!( service::decide_request(&f.db, carol, f.team.id, request, f.decision("force")) .await .is_err() ); f.team = service::decide_request(&f.db, bob, f.team.id, request, f.decision("approve")) .await .unwrap(); assert_eq!(f.team.document.requests[0].status, RequestStatus::Applied); assert_eq!( engine::timeline(&f.team.document, leg.start, leg.end) .unwrap() .assignments[0] .user_id, bob ); assert_eq!( engine::timeline(&f.team.document, back.start, back.end) .unwrap() .assignments[0] .user_id, owner ); assert!( service::decide_request(&f.db, bob, f.team.id, request, f.decision("approve")) .await .is_err() ); let audit = f.db.audit(f.team.id).await.unwrap(); assert_eq!(audit[0]["actor"], f.people[1].username); assert_eq!(audit[0]["action"], "replacement.approve"); assert_eq!( f.db.sync_status(f.team.id).await.unwrap()["requested_revision"], f.team.revision ); f.prioritize_publication().await; let (a, b) = tokio::join!(worker::tick(&f.db), worker::tick(&f.db)); a.unwrap(); b.unwrap(); let status = f.db.sync_status(f.team.id).await.unwrap(); assert_eq!(status["state"], "synced"); assert_eq!(status["published_revision"], f.team.revision); let events = f.db.audit(f.team.id).await.unwrap(); assert_eq!( events .iter() .filter(|e| e["action"] == "publication.succeeded") .count(), 1, "Only one worker may publish a team" ); assert_eq!(f.db.objects(f.team.id).await.unwrap().len(), 1); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() { let f = Fixture::new().await; let actor = f.people[0].id; let team = f.team.id; let mut left = f.team.document.clone(); left.name = "Left".into(); let mut right = left.clone(); right.name = "Right".into(); let (left, right) = tokio::join!( service::update_document(&f.db, actor, team, f.team.revision, left), service::update_document(&f.db, actor, team, f.team.revision, right) ); assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1); let record = f.db.workspace(team).await.unwrap(); assert_eq!(record.revision, f.team.revision + 1); let before = f.db.audit(team).await.unwrap().len(); let mut invalid = record.clone(); invalid.document.name = "Must roll back".into(); // A history row that already owns the next revision's key makes the final statement fail // after the workspace update and job upsert were evaluated: everything must roll back. let planted = format!("{team}:{}", record.revision + 1); f.db.db .raw_with( "INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,$3,'{}',now())", &[&planted, &team.to_string(), &(record.revision + 1)], ) .await .unwrap(); assert!( f.db.commit( &mut invalid, actor, Permission::ManageMembers, "test.invalid", json!({}), Some(Permission::member()) ) .await .is_err() ); f.db.db .raw_with( "DELETE FROM meguri__workspace_history WHERE id=$1", &[&planted], ) .await .unwrap(); assert_eq!( f.db.workspace(team).await.unwrap().revision, record.revision ); assert_eq!(f.db.audit(team).await.unwrap().len(), before); assert_eq!( f.db.sync_status(team).await.unwrap()["requested_revision"], record.revision ); let id = team.to_string(); let rows = query!(HistoryRow,$workspace_id==id) .all(&f.db.db) .await .unwrap(); assert_eq!(rows.len() as i64, record.revision); // An old publication snapshot must not accidentally read revisions committed later. let mut old = f.team.clone(); let mut changed = record.document.clone(); if let ScheduleKind::Rotation { participants, .. } = &mut changed.schedules[0].kind { participants.swap(0, 1); } service::update_document(&f.db, actor, team, record.revision, changed) .await .unwrap(); let old_plan = f.db.historical_timeline(&old, f.start, f.start + Duration::hours(1)) .await .unwrap(); assert_eq!(old_plan.assignments[0].user_id, actor); old = f.db.workspace(team).await.unwrap(); let new_plan = f.db.historical_timeline(&old, f.start, f.start + Duration::hours(1)) .await .unwrap(); assert_eq!(new_plan.assignments[0].user_id, f.people[1].id); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn volunteer_cover_preview_owner_approval_optional_comments_and_remaining_shift() { let mut f = Fixture::new().await; let [alice, bob, carol] = f.people.clone().map(|p| p.id); let team = f.team.id; let input = |revision, force, legs| service::NewRequest { revision, remaining: false, reason: String::new(), legs, force, }; let preview = service::preview_replacement( &f.db, bob, team, input(f.team.revision, false, vec![f.leg()]), ) .await .unwrap(); assert_eq!(preview["legs"][0]["to_user"], bob.to_string()); assert_eq!( f.db.workspace(team).await.unwrap().revision, f.team.revision ); assert!( service::request_replacement( &f.db, carol, team, input(f.team.revision, false, vec![f.leg()]) ) .await .is_err(), "Unrelated members cannot transfer others' shifts" ); assert!( service::request_replacement( &f.db, bob, team, input(f.team.revision, true, vec![f.leg()]) ) .await .is_err(), "Volunteering grants no force permission" ); f.team = service::request_replacement( &f.db, bob, team, input(f.team.revision, false, vec![f.leg()]), ) .await .unwrap(); let request = f.team.document.requests.last().unwrap(); assert_eq!(request.required_approvers, [alice].into()); assert_eq!(request.reason, ""); let id = request.id; assert!( service::decide_request(&f.db, bob, team, id, f.decision("approve")) .await .is_err() ); f.team = service::decide_request(&f.db, alice, team, id, f.decision("approve")) .await .unwrap(); assert_eq!( f.team.document.requests.last().unwrap().status, RequestStatus::Applied ); let mut next = f.leg(); next.start += Duration::days(1); next.end += Duration::days(1); f.team = service::request_replacement(&f.db, alice, team, input(f.team.revision, true, vec![next])) .await .unwrap(); let forced = f.team.document.requests.last().unwrap(); assert_eq!(forced.force_reason.as_deref(), Some("")); assert_eq!(forced.forced_by, Some(alice)); assert_eq!(forced.status, RequestStatus::Applied); // Current shifts use the server clock. Approval clips the remaining interval, never history. let mut doc = f.team.document.clone(); let current = Uuid::new_v4(); doc.schedules.push(Schedule { id: current, name: "Current duty".into(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![alice], anchor: "2026-09-21".parse().unwrap(), shift_days: 7, }, }); f.team = service::update_document(&f.db, alice, team, f.team.revision, doc) .await .unwrap(); let mut current_input = input( f.team.revision, false, vec![ReplacementLeg { schedule_id: current, start: Utc::now() - Duration::hours(1), end: Utc::now() + Duration::hours(2), from_user: alice, to_user: bob, }], ); current_input.remaining = true; current_input.reason = "OPS-123 · vacation".into(); let before = Utc::now(); f.team = service::request_replacement(&f.db, bob, team, current_input) .await .unwrap(); let request = f.team.document.requests.last().unwrap(); assert!(request.legs[0].start >= before); let id = request.id; let before_approval = Utc::now(); f.team = service::decide_request(&f.db, alice, team, id, f.decision("approve")) .await .unwrap(); let approved = f.team.document.requests.last().unwrap(); assert!(approved.legs[0].start >= before_approval); assert_eq!(approved.reason, "OPS-123 · vacation"); assert!( f.db.audit(team) .await .unwrap() .iter() .any(|e| e["action"] == "replacement.forced") ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn approval_revalidates_ownership_absence_and_overlap_and_force_is_audited() { let mut f = Fixture::new().await; let owner = f.people[0].id; let bob = f.people[1].id; let team = f.team.id; let leg = f.leg(); f.propose(vec![leg.clone()], false).await; let request = f.team.document.requests[0].id; let mut doc = f.team.document.clone(); doc.absences.push(Absence { id: Uuid::new_v4(), user_id: bob, start: leg.start.date_naive(), end: leg.start.date_naive(), }); f.team = service::update_document(&f.db, owner, team, f.team.revision, doc) .await .unwrap(); assert!( service::decide_request(&f.db, bob, team, request, f.decision("approve")) .await .is_err() ); let mut doc = f.team.document.clone(); doc.absences.clear(); if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { participants.swap(0, 2); } f.team = service::update_document(&f.db, owner, team, f.team.revision, doc) .await .unwrap(); assert!( service::decide_request(&f.db, bob, team, request, f.decision("approve")) .await .is_err() ); let mut doc = f.team.document.clone(); if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { participants.swap(0, 2); } f.team = service::update_document(&f.db, owner, team, f.team.revision, doc) .await .unwrap(); f.propose(vec![leg], true).await; assert!( service::decide_request(&f.db, bob, team, request, f.decision("approve")) .await .is_err() ); let applied = f.team.document.requests.last().unwrap(); assert_eq!(applied.forced_by, Some(owner)); assert_eq!(applied.status, RequestStatus::Applied); assert!( f.db.audit(team) .await .unwrap() .iter() .any(|e| e["action"] == "replacement.forced") ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_contract_retries_partial_writes_blocks_drift_and_retains_unmanaged_resources() { let mut f = Fixture::new().await; let server = fake_jsm::Server::start().await; let remote_team = format!("og-{}", Uuid::new_v4()); // Managed resources sort after these, exercising recovery beyond the first page. for index in 0..51 { server.state.lock().unwrap().objects.insert( format!("/schedules/000-unmanaged-{index:02}"), json!({"id":format!("000-unmanaged-{index:02}"),"name":format!("Unmanaged {index}")}), ); } let maps = f .people .iter() .map(|p| (p.id, p.username.clone())) .collect(); let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "test-token".into(), remote_team.clone(), maps, ) .unwrap(); let policy = Uuid::new_v4(); f.team.document.escalations.push(Escalation { id: policy, name: "Escalation".into(), steps: vec![EscalationStep { delay_minutes: 0, condition: EscalationCondition::IfNotAcked, recipient: Recipient::Schedule { id: f.schedule }, }], repeat: Default::default(), }); f.team.document.routes.push(RoutingRule { id: Uuid::new_v4(), name: "Catch all".into(), order: 9999, conditions: vec![], window: None, escalation_id: Some(policy), }); f.team .document .escalations .last_mut() .unwrap() .steps .push(EscalationStep { delay_minutes: 30, condition: EscalationCondition::IfNotAcked, recipient: Recipient::TeamAll, }); f.team .document .escalations .last_mut() .unwrap() .steps .push(EscalationStep { delay_minutes: 45, condition: EscalationCondition::IfNotAcked, recipient: Recipient::TeamRandom, }); let plan = providers::compile( &f.team, engine::timeline(&f.team.document, f.start, f.start + Duration::days(20)).unwrap(), f.start, ) .unwrap(); server.state.lock().unwrap().fail_once = Some("/escalations".into()); assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); assert_eq!( f.db.objects(f.team.id).await.unwrap().len(), 1, "Successful schedule write must be checkpointed before retry" ); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!( server.state.lock().unwrap().writes, writes, "Retry must not duplicate or rewrite resources" ); let objects = f.db.objects(f.team.id).await.unwrap(); let schedule = objects .iter() .find(|o| o.object_key.starts_with("schedule:")) .unwrap(); let path = format!("/schedules/{}", schedule.remote_id); { let state = server.state.lock().unwrap(); let value = &state.objects[&path]; assert_eq!( value["rotations"][0]["participants"][0]["id"], f.people[0].username ); assert_eq!( value["rotations"][0]["participants"][1]["id"], f.people[1].username ); let route = state .objects .iter() .find(|(k, _)| k.contains("routing-rules/")) .unwrap() .1; assert_eq!(route["order"], 0, "Translate priority to a JSM index"); assert_eq!(route["notify"]["type"], "escalation"); assert!( route["name"] .as_str() .unwrap() .starts_with("Catch all [meguri:") ); assert!( state .objects .iter() .find(|(key, _)| key.contains("escalations/")) .unwrap() .1["name"] .as_str() .unwrap() .starts_with("Escalation [meguri:") ); } { let state = server.state.lock().unwrap(); let escalation = state .objects .iter() .find(|(key, _)| key.contains("/escalations/")) .unwrap() .1; assert_eq!( escalation["rules"][1], json!({"condition":"if-not-acked","notifyType":"all","delay":30,"recipient":{"type":"team","id":remote_team}}) ); assert_eq!( escalation["rules"][2], json!({"condition":"if-not-acked","notifyType":"random","delay":45,"recipient":{"type":"team","id":remote_team}}) ); } // Disabled repeats are omitted by JSM; recovery must adopt a matching // escalation after its initial read-back failed in an older application. let escalation = objects .iter() .find(|o| o.object_key.starts_with("escalation:")) .unwrap(); f.db.forget(f.team.id, &escalation.object_key) .await .unwrap(); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().writes, writes); let escalation_path = format!("/teams/{remote_team}/escalations/{}", escalation.remote_id); server .state .lock() .unwrap() .objects .get_mut(&escalation_path) .unwrap()["repeat"]["waitInterval"] = json!(5); assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); assert_eq!(server.state.lock().unwrap().writes, writes); server .state .lock() .unwrap() .objects .get_mut(&escalation_path) .unwrap()["repeat"]["waitInterval"] = json!(0); // Simulate a crash after remote creation, before the local checkpoint. f.db.forget(f.team.id, &schedule.object_key).await.unwrap(); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().writes, writes); server.state.lock().unwrap().objects.get_mut(&path).unwrap()["timezone"] = json!("Asia/Tokyo"); assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); assert_eq!(server.state.lock().unwrap().writes, writes); client.publish(&f.db, &f.team, &plan, true).await.unwrap(); server.state.lock().unwrap().external_override = true; assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); let mut empty = plan.clone(); empty.schedules.clear(); empty.escalations.clear(); empty.routes.clear(); assert!(matches!( client.publish(&f.db, &f.team, &empty, false).await, Err(Error::Conflict(_)) )); assert!(server.state.lock().unwrap().objects.contains_key(&path)); client.publish(&f.db, &f.team, &plan, true).await.unwrap(); server.state.lock().unwrap().objects.insert( "/schedules/unmanaged".into(), json!({"id":"unmanaged","name":"Do not touch"}), ); client.publish(&f.db, &f.team, &empty, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().objects.len(), 52); assert!( server .state .lock() .unwrap() .objects .contains_key("/schedules/unmanaged") ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_exact_coverage_recovers_lost_responses_preserves_boundaries_and_blocks_manual_edits() { let f = Fixture::new().await; let server = fake_jsm::Server::start().await; let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "fake".into(), format!("og-{}", Uuid::new_v4()), f.people .iter() .map(|p| (p.id, p.username.clone())) .collect(), ) .unwrap(); let mut plan = providers::compile( &f.team, engine::timeline(&f.team.document, f.start, f.start + Duration::days(7)).unwrap(), f.start, ) .unwrap(); let mut first = plan.schedules[0].periods[0].clone(); first.start = f.start + Duration::minutes(1) + Duration::microseconds(53_868); first.end = first.start + Duration::hours(2) + Duration::minutes(17); let mut second = first.clone(); second.start = first.end; second.end = first.end + Duration::hours(3); second.user_id = f.people[1].id; plan.schedules[0].periods = vec![first.clone(), second.clone()]; plan.schedules[0].recurring = None; server.state.lock().unwrap().fail_after_override_create = true; assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); assert!( f.db.objects(f.team.id) .await .unwrap() .iter() .any(|r| r.object_key.starts_with("coverage:") && r.remote_id.is_empty()) ); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().writes, writes); let rows = f.db.objects(f.team.id).await.unwrap(); let parent = rows .iter() .find(|r| r.object_key.starts_with("schedule:")) .unwrap(); let parent_path = format!("/schedules/{}", parent.remote_id); let mut overrides: Vec<_> = server .state .lock() .unwrap() .objects .iter() .filter(|(key, _)| key.contains("/overrides/")) .map(|(key, value)| (key.clone(), value.clone())) .collect(); overrides.sort_by_key(|(_, value)| value["startDate"].as_str().unwrap().to_owned()); assert_eq!( overrides.len(), 2, "Lost create response must not duplicate coverage" ); assert_eq!( overrides[0].1["startDate"] .as_str() .unwrap() .parse::>() .unwrap() .timestamp_millis(), first.start.timestamp_millis() ); assert_eq!( overrides[0].1["endDate"], overrides[1].1["startDate"], "Shared half-open boundary must remain exact" ); assert_eq!(overrides[1].1["responder"]["id"], f.people[1].username); assert_eq!( server.state.lock().unwrap().objects[&parent_path]["rotations"][0]["participants"][0]["type"], "noone" ); server .state .lock() .unwrap() .objects .get_mut(&overrides[0].0) .unwrap()["responder"]["id"] = json!("external-person"); assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); assert_eq!(server.state.lock().unwrap().writes, writes); client.publish(&f.db, &f.team, &plan, true).await.unwrap(); plan.schedules[0].periods[0].user_id = f.people[2].id; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!( server .state .lock() .unwrap() .objects .values() .filter(|v| v.get("alias").is_some()) .count(), 2 ); // An ongoing first shift must not be recreated as a forbidden past-start override. plan.as_of = first.start + Duration::seconds(1); server.state.lock().unwrap().override_not_before = Some(plan.as_of); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); assert_eq!( state.objects[&parent_path]["rotations"][0]["participants"][0]["id"], f.people[2].username ); let upcoming = state .objects .values() .find(|v| v["responder"]["id"] == f.people[1].username) .unwrap(); assert_eq!( upcoming["startDate"] .as_str() .unwrap() .parse::>() .unwrap() .timestamp_millis(), second.start.timestamp_millis() ); } // The provider's list excludes expired overrides; that is not external drift. plan.as_of = first.end + Duration::seconds(1); server.state.lock().unwrap().override_not_before = Some(plan.as_of); server.state.lock().unwrap().objects.retain(|_, value| { value .get("endDate") .and_then(serde_json::Value::as_str) .and_then(|v| v.parse::>().ok()) .is_none_or(|end| end > plan.as_of) }); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!( f.db.objects(f.team.id) .await .unwrap() .iter() .filter(|r| r.object_key.starts_with("coverage:")) .count(), 1 ); { let state = server.state.lock().unwrap(); let base = &state.objects[&parent_path]["rotations"][0]; assert_eq!(base["participants"][0]["id"], f.people[1].username); assert!( base["startDate"] .as_str() .unwrap() .parse::>() .unwrap() <= plan.as_of ); let tail = state .objects .values() .find(|v| v.get("alias").is_some()) .unwrap(); assert_eq!(tail["responder"]["type"], "noone"); assert_eq!( tail["startDate"] .as_str() .unwrap() .parse::>() .unwrap() .timestamp_millis(), second.end.timestamp_millis() ); } let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().writes, writes); server.state.lock().unwrap().external_override = true; assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); plan.schedules.clear(); assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); client.publish(&f.db, &f.team, &plan, true).await.unwrap(); assert!(server.state.lock().unwrap().objects.is_empty()); assert!(f.db.objects(f.team.id).await.unwrap().is_empty()); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_readable_names_rename_in_place_and_recover_legacy_resources() { let f = Fixture::new().await; let server = fake_jsm::Server::start().await; let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "test-token".into(), format!("og-{}", Uuid::new_v4()), f.people .iter() .map(|p| (p.id, p.username.clone())) .collect(), ) .unwrap(); let mut plan = providers::compile( &f.team, engine::timeline(&f.team.document, f.start, f.start + Duration::days(7)).unwrap(), f.start, ) .unwrap(); plan.schedules[0].name = "Payments · Дежурство".into(); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let objects = f.db.objects(f.team.id).await.unwrap(); let checkpoint = objects .iter() .find(|o| o.object_key.starts_with("schedule:")) .unwrap(); let path = format!("/schedules/{}", checkpoint.remote_id); let first_name = server.state.lock().unwrap().objects[&path]["name"] .as_str() .unwrap() .to_owned(); assert!(first_name.starts_with("Payments · Дежурство [meguri:")); plan.schedules[0].name = "Payments primary".into(); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); assert_eq!(state.objects.len(), 1); let renamed = state.objects[&path]["name"].as_str().unwrap(); assert!(renamed.starts_with("Payments primary [meguri:")); assert_eq!( renamed.split(" [meguri:").last(), first_name.split(" [meguri:").last() ); } // Simulate an old adapter write followed by a lost checkpoint. f.db.forget(f.team.id, &checkpoint.object_key) .await .unwrap(); let legacy_name = format!( "meguri-{}-{}", f.team.id.simple(), plan.schedules[0].id.simple() ); { let mut state = server.state.lock().unwrap(); state.objects.get_mut(&path).unwrap()["name"] = json!(legacy_name); state.objects.get_mut(&path).unwrap()["timezone"] = json!("Asia/Tokyo"); } let writes = server.state.lock().unwrap().writes; assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); assert_eq!(server.state.lock().unwrap().writes, writes); server.state.lock().unwrap().objects.get_mut(&path).unwrap()["timezone"] = json!(plan.schedules[0].timezone); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let state = server.state.lock().unwrap(); assert_eq!(state.objects.len(), 1); assert!( state.objects[&path]["name"] .as_str() .unwrap() .starts_with("Payments primary [meguri:") ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn failed_publication_is_durable_and_recoverable() { let f = Fixture::new().await; let mut doc = f.team.document.clone(); let day = engine::duty_day(&doc, Utc::now()).unwrap(); let actual = engine::timeline(&doc, Utc::now(), Utc::now() + Duration::hours(1)) .unwrap() .assignments[0] .user_id; doc.absences.push(Absence { id: Uuid::new_v4(), user_id: actual, start: day, end: day, }); let record = service::update_document(&f.db, f.people[0].id, f.team.id, f.team.revision, doc) .await .unwrap(); f.prioritize_publication().await; worker::tick(&f.db).await.unwrap(); let status = f.db.sync_status(f.team.id).await.unwrap(); assert_eq!(status["state"], "failed"); assert!(status["last_error"].as_str().unwrap().contains("absent")); let mut doc = record.document.clone(); doc.absences.clear(); let record = service::update_document(&f.db, f.people[0].id, f.team.id, record.revision, doc) .await .unwrap(); f.prioritize_publication().await; worker::tick(&f.db).await.unwrap(); assert_eq!( f.db.sync_status(f.team.id).await.unwrap()["published_revision"], record.revision ); // A successful retry also leaves a durable audit event and clears the lease. let id = f.team.id.to_string(); let job = query!(SyncJobRow,$workspace_id==id) .get(&f.db.db) .await .unwrap() .unwrap(); assert!(job.lease_token.is_none()); let id = f.team.id.to_string(); assert!( query!(AuditRow,$workspace_id==id && $action=="publication.succeeded") .exists(&f.db.db) .await .unwrap() ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn audit_resolves_historical_names_after_rename_and_keeps_raw_ids() { let mut f = Fixture::new().await; let leg = f.leg(); f.propose(vec![leg], true).await; let schedule = f.schedule; f.team.document.schedules[0].name = "Renamed schedule".into(); f.db.commit( &mut f.team, f.people[0].id, Permission::EditSchedules, "test.rename", json!({"schedule_id":schedule}), None, ) .await .unwrap(); let events = f.db.audit(f.team.id).await.unwrap(); let event = events .iter() .find(|e| e["details"].get("legs").is_some()) .unwrap(); assert_eq!( event["resolved_details"]["legs"][0]["schedule_id"]["name"], "General" ); assert_eq!( event["resolved_details"]["legs"][0]["from_user"]["name"], "Alice" ); assert_eq!( event["resolved_details"]["legs"][0]["to_user"]["name"], "Bob" ); assert_eq!(event["details"]["legs"][0]["schedule_id"], json!(schedule)); assert_eq!( events[0]["resolved_details"]["schedule_id"]["name"], "Renamed schedule" ); // Another tenant's identities are not part of this team's lookup context. let other = Fixture::new().await; let other_id = other.people[0].id; f.db.commit( &mut f.team, f.people[0].id, Permission::EditSchedules, "test.unknown", json!({"user_id":other_id}), None, ) .await .unwrap(); let events = f.db.audit(f.team.id).await.unwrap(); assert_eq!(events[0]["resolved_details"]["user_id"], json!(other_id)); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn approved_cover_can_be_replaced_partially_with_approval_and_corrected_again() { let mut f = Fixture::new().await; let original = f.leg(); f.propose(vec![original.clone()], true).await; let original_id = f.team.document.requests[0].id; let correction = ReplacementLeg { start: original.start + Duration::hours(3), end: original.end - Duration::hours(3), from_user: f.people[1].id, to_user: f.people[2].id, ..original.clone() }; f.team = service::request_replacement( &f.db, f.people[1].id, f.team.id, service::NewRequest { remaining: false, revision: f.team.revision, reason: "Correct cover".into(), legs: vec![correction.clone()], force: false, }, ) .await .unwrap(); let pending = f.team.document.requests.last().unwrap().id; assert!(f.team.document.requests[0].effective_legs.is_none()); assert_eq!( engine::timeline(&f.team.document, original.start, original.end) .unwrap() .assignments[0] .user_id, f.people[1].id ); // Ordinary members still cannot skip approval. assert!( service::decide_request( &f.db, f.people[1].id, f.team.id, pending, f.decision("force") ) .await .is_err() ); f.team = service::decide_request( &f.db, f.people[2].id, f.team.id, pending, f.decision("approve"), ) .await .unwrap(); let old = &f.team.document.requests[0]; assert_eq!(old.id, original_id); assert_eq!(old.legs[0].start, original.start); assert_eq!(old.legs[0].end, original.end); assert_eq!(old.effective_legs().len(), 2); let plan = engine::timeline(&f.team.document, original.start, original.end).unwrap(); assert_eq!( plan.assignments .iter() .map(|a| a.user_id) .collect::>(), vec![f.people[1].id, f.people[2].id, f.people[1].id] ); assert_eq!(plan.assignments[0].end, correction.start); assert_eq!(plan.assignments[2].start, correction.end); // A pending request becomes stale if another correction changes its owner. f.team = service::request_replacement( &f.db, f.people[2].id, f.team.id, service::NewRequest { remaining: false, revision: f.team.revision, reason: String::new(), legs: vec![ReplacementLeg { from_user: f.people[2].id, to_user: f.people[1].id, ..correction.clone() }], force: false, }, ) .await .unwrap(); let stale = f.team.document.requests.last().unwrap().id; f.propose( vec![ReplacementLeg { from_user: f.people[2].id, to_user: f.people[0].id, ..correction.clone() }], true, ) .await; assert!(f.team.document.requests[1].effective_legs().is_empty()); assert_eq!(f.team.document.requests[1].legs.len(), 1); assert!( service::decide_request( &f.db, f.people[1].id, f.team.id, stale, f.decision("approve") ) .await .is_err() ); assert_eq!( engine::timeline(&f.team.document, correction.start, correction.end) .unwrap() .assignments[0] .user_id, f.people[0].id ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn local_absence_range_preserves_approval_and_target_interval() { use meguri::absence::{CoverRange, Draft, ExactCover, LocalCover}; let mut f = Fixture::new().await; let day = engine::schedule_duty_day(&f.team.document, f.schedule, f.start).unwrap(); let exact = ExactCover { schedule_id: f.schedule, start: f.start + Duration::hours(2), end: f.start + Duration::hours(5), }; f.team = service::create_absence( &f.db, f.people[0].id, f.team.id, service::NewAbsence { revision: f.team.revision, people: vec![], force: false, reason: String::new(), draft: Draft { absence: Absence { id: Uuid::new_v4(), user_id: f.people[0].id, start: day, end: day, }, ranges: vec![CoverRange { local: Some(LocalCover { schedule_id: Some(f.schedule), start: exact .start .with_timezone( &f.team.document.timezone.parse::().unwrap(), ) .naive_local(), end: exact .end .with_timezone( &f.team.document.timezone.parse::().unwrap(), ) .naive_local(), }), start: day, end: day, replacement: f.people[1].id, replace_projects: false, exact: None, }], }, }, ) .await .unwrap(); let request = &f.team.document.requests[0]; assert_eq!(request.status, RequestStatus::Pending); let id = request.id; assert_eq!( (request.legs[0].start, request.legs[0].end), (exact.start, exact.end) ); assert!( engine::timeline(&f.team.document, exact.start, exact.end) .unwrap() .assignments[0] .unavailable ); f.team = service::decide_request(&f.db, f.people[1].id, f.team.id, id, f.decision("approve")) .await .unwrap(); assert_eq!( engine::timeline(&f.team.document, exact.start, exact.end) .unwrap() .assignments[0] .user_id, f.people[1].id ); assert!( engine::timeline(&f.team.document, f.start, exact.start) .unwrap() .assignments[0] .unavailable ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_recreates_deleted_resources_and_recovers_lost_replacement_responses() { let mut f = Fixture::new().await; let server = fake_jsm::Server::start().await; let remote_team = format!("og-{}", Uuid::new_v4()); // Managed resources sort after these, exercising recovery beyond the first page. for index in 0..51 { server.state.lock().unwrap().objects.insert( format!("/schedules/000-unmanaged-{index:02}"), json!({"id":format!("000-unmanaged-{index:02}"),"name":format!("Unmanaged {index}")}), ); } let maps = f .people .iter() .map(|p| (p.id, p.username.clone())) .collect(); let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "test-token".into(), remote_team.clone(), maps, ) .unwrap(); let policy = Uuid::new_v4(); f.team.document.escalations.push(Escalation { id: policy, name: "Escalation".into(), steps: vec![EscalationStep { delay_minutes: 0, condition: EscalationCondition::IfNotAcked, recipient: Recipient::Schedule { id: f.schedule }, }], repeat: Default::default(), }); f.team.document.routes.push(RoutingRule { id: Uuid::new_v4(), name: "Catch all".into(), order: 9999, conditions: vec![], window: None, escalation_id: Some(policy), }); let mut plan = providers::compile( &f.team, engine::timeline(&f.team.document, f.start, f.start + Duration::days(20)).unwrap(), f.start, ) .unwrap(); plan.schedules[0].periods[0].start += Duration::minutes(1); plan.schedules[0].recurring = None; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); for kind in ["schedule", "escalation", "route", "coverage"] { let old = f.db.objects(f.team.id) .await .unwrap() .into_iter() .find(|r| r.object_key.starts_with(&format!("{kind}:"))) .unwrap(); { let mut state = server.state.lock().unwrap(); let path = state .objects .keys() .find(|path| path.ends_with(&format!("/{}", old.remote_id))) .unwrap() .clone(); state .objects .retain(|key, _| key != &path && !key.starts_with(&format!("{path}/"))); if kind != "coverage" { state.fail_after_resource_create = true; } } if kind != "coverage" { assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); } client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let rows = f.db.objects(f.team.id).await.unwrap(); let replacement = rows .iter() .find(|r| r.object_key == old.object_key) .unwrap(); assert_ne!(replacement.remote_id, old.remote_id); assert!( rows.iter().all(|r| !r.content.contains(&old.remote_id)), "Dependent checkpoints retain a deleted ID" ); let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let state = server.state.lock().unwrap(); assert_eq!( state.writes, writes, "Retry must not create duplicate resources" ); assert_eq!( state .objects .values() .filter(|v| v["name"] .as_str() .is_some_and(|name| name.contains("[meguri:"))) .count(), 3 ); } } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn publication_requires_explicit_revision_and_never_picks_up_later_edits() { let mut f = Fixture::new().await; let id = f.team.id; let actor = f.people[0].id; worker::tick(&f.db).await.unwrap(); assert!(f.db.objects(id).await.unwrap().is_empty()); assert!(f.db.sync_status(id).await.unwrap()["published_revision"].is_null()); assert!( f.db.sync_status(id).await.unwrap()["has_changes"] .as_bool() .unwrap() ); assert_eq!( f.db.sync_status(id).await.unwrap()["changes"][0]["action"], "create" ); assert!( f.db.retry(id, f.people[1].id, f.team.revision) .await .is_err() ); assert!(f.db.retry(id, actor, f.team.revision - 1).await.is_err()); f.prioritize_publication().await; let authorized = f.team.revision; let mut doc = f.team.document.clone(); doc.schedules[0].name = "Unpublished rename".into(); f.team = service::update_document(&f.db, actor, id, f.team.revision, doc) .await .unwrap(); worker::tick(&f.db).await.unwrap(); let status = f.db.sync_status(id).await.unwrap(); assert_eq!(status["published_revision"], authorized); assert_eq!(status["has_changes"], true); assert_ne!( f.db.published_document(id) .await .unwrap() .unwrap() .schedules[0] .name, "Unpublished rename" ); let successes = f.db.audit(id) .await .unwrap() .iter() .filter(|e| e["action"] == "publication.succeeded") .count(); worker::tick(&f.db).await.unwrap(); assert_eq!( f.db.audit(id) .await .unwrap() .iter() .filter(|e| e["action"] == "publication.succeeded") .count(), successes ); f.prioritize_publication().await; worker::tick(&f.db).await.unwrap(); assert_eq!(f.db.sync_status(id).await.unwrap()["has_changes"], false); assert_eq!( f.db.sync_status(id).await.unwrap()["changes"][0]["action"], "unchanged" ); // A failing approved snapshot must keep failing after an unapproved correction. let mut doc = f.team.document.clone(); let now = Utc::now(); let user = engine::timeline(&doc, now, now + Duration::hours(1)) .unwrap() .assignments[0] .user_id; let day = engine::duty_day(&doc, now).unwrap(); doc.absences.push(Absence { id: Uuid::new_v4(), user_id: user, start: day, end: day, }); f.team = service::update_document(&f.db, actor, id, f.team.revision, doc) .await .unwrap(); f.prioritize_publication().await; let approved = f.team.revision; let mut doc = f.team.document.clone(); doc.absences.clear(); f.team = service::update_document(&f.db, actor, id, f.team.revision, doc) .await .unwrap(); worker::tick(&f.db).await.unwrap(); let status = f.db.sync_status(id).await.unwrap(); assert_eq!(status["state"], "failed"); assert_eq!(status["authorized_revision"], approved); f.db.db.raw_with("UPDATE meguri__sync_jobs SET next_attempt='1970-01-01T00:00:00Z' WHERE workspace_id=$1",&[&id.to_string()]).await.unwrap(); worker::tick(&f.db).await.unwrap(); assert_eq!(f.db.sync_status(id).await.unwrap()["state"], "failed"); f.prioritize_publication().await; worker::tick(&f.db).await.unwrap(); assert_eq!(f.db.sync_status(id).await.unwrap()["has_changes"], false); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn shared_generator_drafts_are_scoped_durable_and_revision_checked() { let mut f = Fixture::new().await; let actor = f.people[0].id; let colleague = f.people[1].id; let id = f.team.id; // A second administrator may edit shared drafts; the employee role here cannot. f.make_admin(&f.people[1]).await; f.team = f.db.workspace(id).await.unwrap(); let payload = json!({"revision":f.team.revision,"variables":[],"rules":[],"route_position":0}); let saved = f.db.save_shared_draft(id, actor, 0, payload.clone()) .await .unwrap(); assert_eq!(saved["revision"], 1); let reader = meguri::store::Store::connect(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); reader .require(id, colleague, Permission::View) .await .unwrap(); assert_eq!(reader.shared_draft(id).await.unwrap(), saved); assert!( f.db.save_shared_draft(id, colleague, 0, payload.clone()) .await .is_err() ); let saved = f.db.save_shared_draft(id, colleague, 1, payload.clone()) .await .unwrap(); assert_eq!(saved["revision"], 2); assert!( f.db.save_shared_draft(id, f.people[2].id, 2, payload.clone()) .await .is_err() ); let other = f.extra_workspace("Other shared draft", &f.people[0]).await; assert!( f.db.save_shared_draft( other.id, colleague, 0, json!({"revision":other.revision,"variables":[],"rules":[]}) ) .await .is_err() ); worker::tick(&f.db).await.unwrap(); assert!(f.db.objects(id).await.unwrap().is_empty()); let mut draft: meguri::generation_service::Draft = serde_json::from_value(payload).unwrap(); draft.shared_revision = Some(1); let preview = meguri::generation_service::preview(&f.db, actor, id, draft.clone()) .await .unwrap(); draft.review_id = preview["review_id"].as_str().map(str::to_owned); assert!( meguri::generation_service::apply(&f.db, actor, id, draft) .await .is_err(), "A newer shared draft must block stale activation" ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_compacts_existing_weekly_shifts_without_recreating_the_schedule() { let mut f = Fixture::new().await; f.team.document.timezone = "Europe/Bucharest".into(); f.team.document.schedules[0].kind = ScheduleKind::Rotation { participants: vec![f.people[0].id, f.people[1].id], anchor: "2026-09-21".parse().unwrap(), shift_days: 7, }; let from = "2026-09-30T12:00:00Z".parse().unwrap(); let to = "2026-12-28T09:00:00Z".parse().unwrap(); let plan = providers::compile( &f.team, engine::timeline(&f.team.document, from, to).unwrap(), from, ) .unwrap(); let server = fake_jsm::Server::start().await; let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "fake".into(), Uuid::new_v4().to_string(), f.people .iter() .map(|p| (p.id, p.username.clone())) .collect(), ) .unwrap(); let mut old = plan.clone(); old.schedules[0].recurring = None; client.publish(&f.db, &f.team, &old, false).await.unwrap(); let parent = f.db.objects(f.team.id) .await .unwrap() .into_iter() .find(|r| r.object_key.starts_with("schedule:")) .unwrap(); let path = format!("/schedules/{}", parent.remote_id); assert!( server.state.lock().unwrap().objects[&path]["rotations"] .as_array() .unwrap() .len() > 1 ); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); assert_eq!(state.objects.len(), 1); let rotations = state.objects[&path]["rotations"].as_array().unwrap(); assert_eq!(rotations.len(), 1); assert_eq!(rotations[0]["name"], "General"); assert_eq!(rotations[0]["type"], "weekly"); assert_eq!(rotations[0]["length"], 1); assert_eq!(rotations[0]["participants"][0]["id"], f.people[1].username); assert_eq!(rotations[0]["participants"][1]["id"], f.people[0].username); assert_eq!(rotations[0]["startDate"], "2026-09-28T08:00:00Z"); assert_eq!(rotations[0]["endDate"], "2026-12-28T09:00:00Z"); } let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); assert_eq!(server.state.lock().unwrap().writes, writes); server.state.lock().unwrap().objects.get_mut(&path).unwrap()["rotations"][0]["participants"] .as_array_mut() .unwrap() .reverse(); assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn jsm_reorders_and_renames_rotation_in_place_with_lost_response_recovery() { let mut f = Fixture::new().await; let server = fake_jsm::Server::start().await; let client = providers::jsm::Jsm::new( server.base.clone(), "test@example.com".into(), "fake".into(), Uuid::new_v4().to_string(), f.people .iter() .map(|p| (p.id, p.username.clone())) .collect(), ) .unwrap(); let compile = |record: &meguri::store::WorkspaceRecord| { providers::compile( record, engine::timeline(&record.document, f.start, f.start + Duration::days(60)).unwrap(), f.start, ) .unwrap() }; client .publish(&f.db, &f.team, &compile(&f.team), false) .await .unwrap(); let parent = f.db.objects(f.team.id) .await .unwrap() .into_iter() .find(|r| r.object_key.starts_with("schedule:")) .unwrap(); let path = format!("/schedules/{}", parent.remote_id); let rotation_id = server.state.lock().unwrap().objects[&path]["rotations"][0]["id"].clone(); let ScheduleKind::Rotation { participants, .. } = &mut f.team.document.schedules[0].kind else { panic!() }; participants.swap(0, 1); server.state.lock().unwrap().fail_after_rotation_patch = true; let plan = compile(&f.team); assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); assert_eq!(state.rotation_creates, 1); assert_eq!(state.rotation_patches, 1); assert_eq!(state.objects[&path]["rotations"][0]["id"], rotation_id); assert_eq!( state.objects[&path]["rotations"][0]["participants"][0]["id"], f.people[1].username ); } // Metadata succeeds remotely but its response is lost before rotation rename. f.team.document.schedules[0].name = "Renamed on call".into(); let plan = compile(&f.team); server.state.lock().unwrap().fail_after_schedule_patch = true; assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err()); let correct = server.state.lock().unwrap().objects[&path]["rotations"][0].clone(); server.state.lock().unwrap().objects.get_mut(&path).unwrap()["rotations"][0]["participants"] [0]["id"] = json!("external-person"); let writes_before_conflict = server.state.lock().unwrap().writes; assert!(matches!( client.publish(&f.db, &f.team, &plan, false).await, Err(Error::Conflict(_)) )); assert_eq!(server.state.lock().unwrap().writes, writes_before_conflict); server.state.lock().unwrap().objects.get_mut(&path).unwrap()["rotations"][0] = correct; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); let writes = server.state.lock().unwrap().writes; client.publish(&f.db, &f.team, &plan, false).await.unwrap(); { let state = server.state.lock().unwrap(); assert_eq!(state.rotation_creates, 1); assert_eq!(state.rotation_patches, 2); assert_eq!(state.writes, writes); assert_eq!(state.objects[&path]["rotations"][0]["id"], rotation_id); assert_eq!( state.objects[&path]["rotations"][0]["name"], "Renamed on call" ); } assert!( f.db.objects(f.team.id) .await .unwrap() .iter() .all(|r| !r.object_key.starts_with("pending:")) ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn expired_publication_lease_allows_manual_recovery() { let f = Fixture::new().await; let id = f.team.id; let actor = f.people[0].id; f.db.retry(id, actor, f.team.revision).await.unwrap(); f.db.db.raw_with("UPDATE meguri__sync_jobs SET state='publishing',lease_token='interrupted-worker',lease_until=now()+interval '5 minutes' WHERE workspace_id=$1", &[&id.to_string()]).await.unwrap(); assert_eq!( f.db.sync_status(id).await.unwrap()["publication_busy"], true ); assert!(f.db.retry(id, actor, f.team.revision).await.is_err()); f.db.db.raw_with("UPDATE meguri__sync_jobs SET lease_until=now()-interval '1 second' WHERE workspace_id=$1", &[&id.to_string()]).await.unwrap(); let status = f.db.sync_status(id).await.unwrap(); assert_eq!(status["state"], "publishing"); assert_eq!(status["publication_busy"], false); f.db.retry(id, actor, f.team.revision).await.unwrap(); assert_eq!(f.db.sync_status(id).await.unwrap()["state"], "pending"); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn portable_import_preview_is_scoped_revision_checked_and_only_saves_shared_draft() { use meguri::generation_service::{self, Transfer}; let f = Fixture::new().await; let other = Fixture::new().await; let text=json!({"format":"meguri/v1","variables":[{"name":"portable greeting","node":{"kind":"text","value":"Hello"}}],"generators":[]}).to_string(); let request = |review_id| Transfer { revision: f.team.revision, shared_revision: 0, text: text.clone(), review_id, }; assert!( generation_service::import_config( &f.db, other.people[0].id, f.team.id, request(None), false ) .await .is_err() ); assert!( generation_service::export_config(&f.db, other.people[0].id, f.team.id, request(None)) .await .is_err() ); let before = f.db.shared_draft(f.team.id).await.unwrap(); let preview = generation_service::import_config(&f.db, f.people[0].id, f.team.id, request(None), false) .await .unwrap(); assert!( preview["preview"]["issues"].as_array().unwrap().is_empty(), "{preview}" ); assert_eq!(before, f.db.shared_draft(f.team.id).await.unwrap()); assert!( generation_service::import_config( &f.db, f.people[0].id, f.team.id, request(Some("wrong-review".into())), true ) .await .is_err() ); let token = preview["review_id"].as_str().unwrap().to_owned(); let saved = generation_service::import_config( &f.db, f.people[0].id, f.team.id, request(Some(token.clone())), true, ) .await .unwrap(); assert_eq!( saved["payload"]["variables"][0]["name"], "portable greeting" ); let record = f.db.workspace(f.team.id).await.unwrap(); assert_eq!(record.revision, f.team.revision); assert_eq!( serde_json::to_value(record.document).unwrap(), serde_json::to_value(&f.team.document).unwrap() ); assert!( generation_service::import_config( &f.db, f.people[0].id, f.team.id, request(Some(token)), true ) .await .is_err() ); let exported = generation_service::export_config( &f.db, f.people[0].id, f.team.id, Transfer { shared_revision: 1, ..request(None) }, ) .await .unwrap(); assert!( exported["text"] .as_str() .unwrap() .contains("portable greeting") ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn contact_replacements_skip_only_contact_approvals_and_preserve_permissions() { let mut f = Fixture::new().await; let actor = f.people[0].id; let contact = Uuid::new_v4(); let mut doc = f.team.document.clone(); doc.people.push(Person { id: contact, user_id: None, display_name: "Planning contact".into(), email: Some("planning@example.com".into()), country: Some("CY".into()), aliases: vec![], }); f.team = service::update_document(&f.db, actor, f.team.id, f.team.revision, doc) .await .unwrap(); let mut leg = f.leg(); leg.to_user = contact; let input = |revision, legs| service::NewRequest { revision, legs, force: false, remaining: false, reason: String::new(), }; let preview = service::preview_replacement( &f.db, actor, f.team.id, input(f.team.revision, vec![leg.clone()]), ) .await .unwrap(); assert_eq!(preview["applies_immediately"], true); f.team = service::request_replacement( &f.db, actor, f.team.id, input(f.team.revision, vec![leg.clone()]), ) .await .unwrap(); let request = f.team.document.requests.last().unwrap(); assert_eq!(request.status, RequestStatus::Applied); assert!(request.required_approvers.is_empty()); assert!(request.forced_by.is_none()); assert!(request.applied_at.is_some()); assert!( f.db.require(f.team.id, contact, Permission::View) .await .is_err() ); let key = contact.to_string(); assert!( !query!(meguri::models::UserRow, $id == key) .exists(&f.db.db) .await .unwrap() ); leg.from_user = contact; leg.to_user = f.people[1].id; // An unrelated ordinary member cannot plan on behalf of a contact. assert!( service::request_replacement( &f.db, f.people[2].id, f.team.id, input(f.team.revision, vec![leg.clone()]) ) .await .is_err() ); f.team = service::request_replacement(&f.db, actor, f.team.id, input(f.team.revision, vec![leg])) .await .unwrap(); let request = f.team.document.requests.last().unwrap(); assert_eq!(request.status, RequestStatus::Pending); assert_eq!(request.required_approvers, [f.people[1].id].into()); let id = request.id; f.team = service::decide_request(&f.db, f.people[1].id, f.team.id, id, f.decision("approve")) .await .unwrap(); assert_eq!( f.team.document.requests.last().unwrap().status, RequestStatus::Applied ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn contact_absence_cover_applies_without_force_or_accounts() { use meguri::absence::{CoverRange, Draft}; let mut f = Fixture::new().await; let actor = f.people[0].id; let contacts = [Uuid::new_v4(), Uuid::new_v4()]; let mut doc = f.team.document.clone(); for (index, id) in contacts.into_iter().enumerate() { doc.people.push(Person { id, user_id: None, display_name: format!("Contact {index}"), email: Some(format!("contact{index}@example.com")), country: Some("CY".into()), aliases: vec![], }); } if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { *participants = vec![contacts[0]]; } f.team = service::update_document(&f.db, actor, f.team.id, f.team.revision, doc) .await .unwrap(); let start = f.start.date_naive(); let input = service::NewAbsence { revision: f.team.revision, force: false, reason: String::new(), people: vec![], draft: Draft { absence: Absence { id: Uuid::new_v4(), user_id: contacts[0], start, end: start, }, ranges: vec![CoverRange { local: None, exact: None, start, end: start, replacement: contacts[1], replace_projects: false, }], }, }; let preview = service::preview_absence( &f.db, actor, f.team.id, service::NewAbsence { revision: input.revision, draft: input.draft.clone(), people: vec![], force: false, reason: String::new(), }, ) .await .unwrap(); assert_eq!(preview["plan"]["errors"], json!([])); assert!(!preview["approval_groups"].as_array().unwrap().is_empty()); assert_eq!( preview["approval_groups"][0]["required_approvers"], json!([]) ); f.team = service::create_absence(&f.db, actor, f.team.id, input) .await .unwrap(); assert!(!f.team.document.requests.is_empty()); for request in &f.team.document.requests { assert_eq!(request.status, RequestStatus::Applied); assert!(request.forced_by.is_none()); assert!(request.required_approvers.is_empty()); } assert_eq!(f.db.member_ids(f.team.id).await.unwrap().len(), 3); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn account_owns_person_email_and_workspace_cannot_override_it() { use meguri::admin::{self, NewUser}; let mut f = Fixture::new().await; let lead = f.people[0].id; let username = format!("identity-{}", Uuid::new_v4().simple()); let email = format!("{username}@example.com"); let created = admin::create_password_user( &f.db, &f.config, None, NewUser { username: username.clone(), display_name: "Account".into(), password: "test-password-only".into(), email: Some(format!(" {} ", email.to_uppercase())), groups: vec![], country: None, }, ) .await .unwrap(); let account: User = serde_json::from_value(created["user"].clone()).unwrap(); assert_eq!(account.email.as_deref(), Some(email.as_str())); assert_eq!(account.person.user_id, Some(account.id)); assert!( f.db.require(f.team.id, account.id, Permission::View) .await .is_err() ); // Access comes from the group mapping; the account's Person joins the workspace document at // the next committed revision. meguri::identity::apply_access( &f.db, &f.config, account.id, Some(f.config.workspaces[0].member_groups.clone()), ) .await .unwrap(); f.team = service::update_document( &f.db, lead, f.team.id, f.team.revision, f.team.document.clone(), ) .await .unwrap(); assert_eq!( f.team.document.person(account.id).unwrap().email, account.email ); let mut forged = f.team.document.clone(); forged .people .iter_mut() .find(|p| p.id == account.id) .unwrap() .email = Some("wrong@example.com".into()); assert!( service::update_document(&f.db, lead, f.team.id, f.team.revision, forged) .await .is_err() ); let mut row = query!(meguri::models::UserRow,$id==account.id.to_string()) .get(&f.db.db) .await .unwrap() .unwrap(); row.display_name = "Renamed account".into(); service::save_profile(&f.db, &row).await.unwrap(); let saved = f.db.workspace(f.team.id).await.unwrap(); assert_eq!( saved.document.person(account.id).unwrap().display_name, "Renamed account" ); assert_eq!( saved.document.person(account.id).unwrap().email.as_deref(), Some(email.as_str()) ); assert_eq!(saved.revision, f.team.revision + 1); let base = meguri::generation_service::Draft { revision: saved.revision, shared_revision: None, variables: vec![], rules: vec![], route_position: 0, review_id: None, }; let config = json!({"format":"meguri/v2","variables":{"owner":email},"generators":[]}).to_string(); let (imported, _) = meguri::portable::import(f.team.id, &saved.document, &base, &config).unwrap(); assert!( serde_json::to_string(&imported.variables) .unwrap() .contains(&account.id.to_string()) ); let exported = meguri::portable::export(&saved.document, &imported).unwrap(); assert_eq!(exported["variables"]["owner"], email); // An account created with a contact's email does not take over that contact. let contact = Uuid::new_v4(); let contact_email = format!("{contact}@example.com"); let mut doc = saved.document.clone(); doc.people.push(Person { id: contact, user_id: None, display_name: "Contact".into(), email: Some(contact_email.clone()), country: Some("CY".into()), aliases: vec![], }); f.team = service::update_document(&f.db, f.people[0].id, f.team.id, saved.revision, doc) .await .unwrap(); let newcomer: User = serde_json::from_value( admin::create_password_user( &f.db, &f.config, None, NewUser { username: format!("new-{}", Uuid::new_v4().simple()), display_name: "New".into(), password: "test-password-only".into(), email: Some(contact_email), groups: vec![], country: None, }, ) .await .unwrap()["user"] .clone(), ) .unwrap(); assert_ne!(newcomer.id, contact); assert!( f.db.require(f.team.id, newcomer.id, Permission::View) .await .is_err() ); assert_eq!( f.db.workspace(f.team.id) .await .unwrap() .document .person(contact) .unwrap() .user_id, None ); }