#[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 and CREATEDB for isolated lifecycle tests"] async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() { use cot::db::{Database, Model}; use meguri::{ demo_runtime as runtime, models::{UserRow, WorkspaceRow}, store::Store, }; let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple()); admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap(); let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap(); url.set_path(&format!("/{name}")); let source_url = runtime::application_url(url.as_str(), false).unwrap(); let source = Store::connect(&source_url).await.unwrap(); let owner = service::register( &source, "protected-owner".into(), "Keep me".into(), "test-password-only".into(), ) .await .unwrap(); let tenant = service::create_workspace(&source, owner.id, "Protected workspace".into()) .await .unwrap(); assert!(!runtime::is_demo_database(&source.db).await); assert!(runtime::reset(&source.db).await.is_err()); let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple()); url.set_path(&format!("/{alternate_name}")); let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str())) .await .unwrap(); assert!(isolated); assert_ne!( reqwest::Url::parse(&selected).unwrap().path(), reqwest::Url::parse(&source_url).unwrap().path() ); let demo = Store::connect(&selected).await.unwrap(); assert!(runtime::is_demo_database(&demo.db).await); meguri::demo::seed(&demo).await.unwrap(); assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 5); assert_eq!( WorkspaceRow::objects().all(&demo.db).await.unwrap().len(), 4 ); let old_user = query!(UserRow,$username=="alice") .get(&demo.db) .await .unwrap() .unwrap(); demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap(); runtime::reset(&demo.db).await.unwrap(); assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty()); assert!(runtime::is_demo_database(&demo.db).await); demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap(); let rerun = Store::connect(&selected).await.unwrap(); meguri::demo::seed(&rerun).await.unwrap(); assert_ne!( query!(UserRow,$username=="alice") .get(&rerun.db) .await .unwrap() .unwrap() .id, old_user.id ); runtime::mark_normal(&source.db).await.unwrap(); assert!(!runtime::is_demo_database(&source.db).await); assert!(runtime::reset(&source.db).await.is_err()); assert!( query!(WorkspaceRow,$id==tenant.id.to_string()) .exists(&source.db) .await .unwrap() ); assert!( runtime::cleanup( &source, Utc::now() + Duration::days(100), &runtime::Retention::default() ) .await .is_err() ); assert!( query!(UserRow,$id==owner.id.to_string()) .exists(&source.db) .await .unwrap() ); // Names are generated above exclusively for this test; never drop the configured DB. admin .raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)")) .await .unwrap(); admin .raw(&format!("DROP DATABASE {name} WITH (FORCE)")) .await .unwrap(); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"] async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() { use cot::db::{Database, Model}; use meguri::{ demo_runtime as runtime, models::{UserRow, WorkspaceRow}, store::Store, }; let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple()); admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap(); let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap(); url.set_path(&format!("/{name}")); let url = runtime::application_url(url.as_str(), true).unwrap(); let raw = Database::new(&url).await.unwrap(); runtime::claim_empty(&raw).await.unwrap(); let db = Store::connect(&url).await.unwrap(); meguri::demo::seed(&db).await.unwrap(); let alice = query!(UserRow,$username=="alice") .get(&db.db) .await .unwrap() .unwrap(); let alice_id = Uuid::parse_str(&alice.id).unwrap(); let visitor = service::register( &db, "visitor".into(), "Temporary visitor".into(), "test-password-only".into(), ) .await .unwrap(); let mut team = service::create_workspace(&db, visitor.id, "Temporary team".into()) .await .unwrap(); let tenant = team.clone(); let original_revision = team.revision; // Editing does not extend lifetime: created_at is the expiration anchor. let mut doc = team.document.clone(); doc.name = "Edited temporary team".into(); team = service::update_document(&db, visitor.id, team.id, team.revision, doc) .await .unwrap(); assert!(team.revision > original_revision); let now = query!(WorkspaceRow,$id==team.id.to_string()) .get(&db.db) .await .unwrap() .unwrap() .created_at; team = service::set_member( &db, visitor.id, team.id, team.revision, "alice".into(), Permission::member(), ) .await .unwrap(); let schedule = Uuid::new_v4(); let mut document = team.document.clone(); document.schedules.push(Schedule { id: schedule, name: "Temporary rotation".into(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![visitor.id], anchor: now.date_naive(), shift_days: 7, }, }); team = service::update_document(&db, visitor.id, team.id, team.revision, document) .await .unwrap(); let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap(); team = service::request_replacement( &db, visitor.id, team.id, service::NewRequest { remaining: false, revision: team.revision, reason: "Demo coverage".into(), force: false, legs: vec![ReplacementLeg { schedule_id: schedule, start, end: start + Duration::hours(1), from_user: visitor.id, to_user: alice_id, }], }, ) .await .unwrap(); let ttl = runtime::Retention { workspaces: 120, accounts: 300, requests: 60, events: 180, purge_grace: 30, }; runtime::cleanup(&db, now + Duration::seconds(61), &ttl) .await .unwrap(); let after = db.workspace(team.id).await.unwrap(); assert!(after.document.requests.is_empty()); assert_eq!(after.revision, team.revision + 1); assert_eq!( query!(SyncJobRow,$workspace_id==team.id.to_string()) .get(&db.db) .await .unwrap() .unwrap() .requested_revision, after.revision ); runtime::cleanup(&db, now + Duration::seconds(119), &ttl) .await .unwrap(); assert!(db.workspace(team.id).await.is_ok()); runtime::cleanup(&db, now + Duration::seconds(121), &ttl) .await .unwrap(); assert!(db.workspace(team.id).await.is_err()); assert!( query!(WorkspaceRow,$id==team.id.to_string()) .get(&db.db) .await .unwrap() .unwrap() .demo_deleted_at .is_some() ); assert!( service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"] .as_array() .unwrap() .is_empty() ); assert!( service::update_document( &db, visitor.id, team.id, team.revision, team.document.clone() ) .await .is_err() ); runtime::cleanup(&db, now + Duration::seconds(152), &ttl) .await .unwrap(); assert!( !query!(WorkspaceRow,$id==team.id.to_string()) .exists(&db.db) .await .unwrap() ); runtime::cleanup(&db, now + Duration::seconds(242), &ttl) .await .unwrap(); assert!( service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"] .as_array() .unwrap() .is_empty() ); runtime::cleanup(&db, now + Duration::seconds(301), &ttl) .await .unwrap(); assert!( service::login(&db, "visitor".into(), "test-password-only".into()) .await .is_err() ); runtime::cleanup(&db, now + Duration::seconds(332), &ttl) .await .unwrap(); assert!( !query!(UserRow,$id==visitor.id.to_string()) .exists(&db.db) .await .unwrap() ); assert!( !query!(WorkspaceRow,$id==tenant.id.to_string()) .exists(&db.db) .await .unwrap() ); assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 5); meguri::demo::seed(&db).await.unwrap(); assert_eq!( service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"] .as_array() .unwrap() .len(), 4 ); assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4); admin .raw(&format!("DROP DATABASE {name} WITH (FORCE)")) .await .unwrap(); } #[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 { 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 = service::create_workspace(&f.db, actor, "Independent workspace".into()) .await .unwrap(); 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()), }); 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 = service::create_workspace(&f.db, actor, "Another team".into()) .await .unwrap(); 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()), }); 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 = service::create_workspace(&f.db, owner, "Private workspace".into()) .await .unwrap(); 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(); assert!( f.db.commit( &mut invalid, actor, Permission::ManageMembers, "test.invalid", json!({}), Some((Uuid::new_v4(), Permission::member())) ) .await .is_err() ); 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 = Uuid::new_v4().to_string(); 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 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"][1]["participants"][0]["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"); } // 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(), 1); assert!( server .state .lock() .unwrap() .objects .contains_key("/schedules/unmanaged") ); } #[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 ); }