#[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(), format!("{}@example.com", Uuid::new_v4()), "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(), format!("{}@example.com", Uuid::new_v4()), "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 { 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 = 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 = 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; f.team = service::set_member( &f.db, actor, id, f.team.revision, f.people[1].username.clone(), vec![ Permission::View, Permission::EditSchedules, Permission::ManageIntegration, ], ) .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 = service::create_workspace(&f.db, actor, "Other shared draft".into()) .await .unwrap(); 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 site_administration_is_explicit_audited_revision_checked_and_auth_is_draft_only() { use meguri::{models::SessionRow, site}; let f = Fixture::new().await; let actor = f.people[0].id; assert!(matches!( site::overview(&f.db, actor).await, Err(Error::Forbidden) )); site::bootstrap(&f.db, &f.people[0].username).await.unwrap(); let state = site::overview(&f.db, actor).await.unwrap(); let mut cfg = state["settings"]["authentication"]["value"].clone(); cfg["oidc_enabled"] = json!(true); cfg["password_enabled"] = json!(false); cfg["issuer"] = json!("https://identity.example.com/realms/test"); cfg["client_id"] = json!("meguri"); cfg["button_label"] = json!("Company login"); let request = json!({"revision":state["revision"],"section_revision":state["settings"]["authentication"]["revision"],"value":cfg}); assert!(matches!( site::mutate(&f.db, f.people[1].id, "authentication", request.clone()).await, Err(Error::Forbidden) )); site::mutate(&f.db, actor, "authentication", request.clone()) .await .unwrap(); assert!( site::mutate(&f.db, actor, "authentication", request) .await .is_err() ); assert_eq!( site::settings(&f.db).await.unwrap().1.button_label, "Company login" ); let account = service::register( &f.db, format!("site-{}", Uuid::new_v4().simple()), "Site login test".into(), format!("{}@example.com", Uuid::new_v4()), "test-password-only".into(), ) .await .unwrap(); // Drafting OIDC-only must not disable real local login. let (_, token, _) = service::login(&f.db, account.username.clone(), "test-password-only".into()) .await .unwrap(); let state = site::overview(&f.db, actor).await.unwrap(); site::mutate(&f.db,actor,"user",json!({"revision":state["revision"],"id":account.id,"display_name":"Suspended account","administrator":false,"disabled":true})).await.unwrap(); assert!( service::login(&f.db, account.username.clone(), "test-password-only".into()) .await .is_err() ); assert!(site::active(&f.db, account.id).await.is_err()); let hash = service::token_hash(&token); assert!( !query!(SessionRow,$token_hash==hash) .exists(&f.db.db) .await .unwrap() ); let other = Fixture::new().await; assert!(f.db.permissions(other.team.id, actor).await.is_err()); let state = site::overview(&f.db, actor).await.unwrap(); site::mutate(&f.db,actor,"workspace",json!({"revision":state["revision"],"id":other.team.id,"workspace_revision":other.team.revision,"name":"Renamed by site admin","owner_id":other.people[1].id})).await.unwrap(); assert!(f.db.permissions(other.team.id, actor).await.is_err()); f.db.owner(other.team.id, other.people[1].id).await.unwrap(); assert_eq!( f.db.workspace(other.team.id).await.unwrap().document.name, "Renamed by site admin" ); assert!( site::overview(&f.db, actor).await.unwrap()["audit"] .as_array() .unwrap() .iter() .any(|e| e["action"] == "site.workspace") ); // Restore the shared test installation's auth draft. let state = site::overview(&f.db, actor).await.unwrap(); site::mutate(&f.db,actor,"authentication",json!({"revision":state["revision"],"section_revision":state["settings"]["authentication"]["revision"],"value":{"oidc_enabled":false,"password_enabled":true,"issuer":"","client_id":"","client_secret_env":"MEGURI_OIDC_CLIENT_SECRET","button_label":"Single sign-on","allow_new_users":false}})).await.unwrap(); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn site_last_administrator_survives_concurrent_demotions() { use cot::db::Database; use meguri::{site, store::Store}; let connection = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); let name = format!("meguri_site_test_{}", Uuid::new_v4().simple()); connection .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 db = Store::connect(url.as_str()).await.unwrap(); let a = service::register( &db, "admin-a".into(), "Admin A".into(), format!("{}@example.com", Uuid::new_v4()), "test-password-only".into(), ) .await .unwrap(); let b = service::register( &db, "admin-b".into(), "Admin B".into(), format!("{}@example.com", Uuid::new_v4()), "test-password-only".into(), ) .await .unwrap(); site::bootstrap(&db, &a.username).await.unwrap(); let rev = site::overview(&db, a.id).await.unwrap()["revision"].clone(); assert!(site::mutate(&db,a.id,"user",json!({"revision":rev,"id":a.id,"display_name":"Admin A","administrator":false,"disabled":false})).await.is_err()); assert_eq!(site::overview(&db, a.id).await.unwrap()["revision"], rev); site::bootstrap(&db, &b.username).await.unwrap(); let rev = site::overview(&db, a.id).await.unwrap()["revision"].clone(); let (one, two) = tokio::join!( site::mutate( &db, a.id, "user", json!({"revision":rev,"id":b.id,"display_name":"B","administrator":false,"disabled":false}) ), site::mutate( &db, b.id, "user", json!({"revision":rev,"id":a.id,"display_name":"A","administrator":false,"disabled":false}) ) ); assert_ne!(one.is_ok(), two.is_ok()); assert!(site::require(&db, a.id).await.is_ok() || site::require(&db, b.id).await.is_ok()); drop(db); connection .raw(&format!("DROP DATABASE {name} WITH (FORCE)")) .await .unwrap(); } #[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()), }); 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()), }); } 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_is_unique_and_workspace_cannot_override_it() { let mut f = Fixture::new().await; let username = format!("identity-{}", Uuid::new_v4().simple()); let email = format!("{username}@example.com"); assert!( service::register( &f.db, username.clone(), "Account".into(), "".into(), "test-password-only".into() ) .await .is_err() ); let account = service::register( &f.db, username.clone(), "Account".into(), format!(" {} ", email.to_uppercase()), "test-password-only".into(), ) .await .unwrap(); assert_eq!(account.email.as_deref(), Some(email.as_str())); assert_eq!(account.person.user_id, Some(account.id)); assert!( service::register( &f.db, format!("other-{}", Uuid::new_v4().simple()), "Other".into(), email.clone(), "test-password-only".into() ) .await .is_err() ); assert!( f.db.require(f.team.id, account.id, Permission::View) .await .is_err() ); f.team = service::set_member( &f.db, f.people[0].id, f.team.id, f.team.revision, username, Permission::member(), ) .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, f.people[0].id, 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); // A password signup with a contact's email proves no ownership of 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()), }); f.team = service::update_document(&f.db, f.people[0].id, f.team.id, saved.revision, doc) .await .unwrap(); let newcomer = service::register( &f.db, format!("new-{}", Uuid::new_v4().simple()), "New".into(), contact_email, "test-password-only".into(), ) .await .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 ); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn account_email_migration_is_atomic_and_preserves_identity_and_history() { use cot::db::{Database, Model}; use meguri::{migrations, models::UserRow, store::Store}; let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); let name = format!("meguri_identity_{}", 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 db = Database::new(url.as_str()).await.unwrap(); cot::db::migrations::MigrationEngine::new(migrations::MIGRATIONS[..10].iter().copied()) .unwrap() .run(&db) .await .unwrap(); let ids = [Uuid::new_v4(), Uuid::new_v4()]; for (id, username) in ids.iter().zip(["legacy-a", "legacy-b"]) { db.raw_with("INSERT INTO meguri__users(id,username,display_name,country,password_hash,created_at) VALUES($1,$2,$2,'CY','unusable',now())",&[&id.to_string(),&username]).await.unwrap(); } let workspace = Uuid::new_v4(); let mut doc = WorkspaceDocument::new("Legacy".into()); doc.people.push(Person { id: ids[0], user_id: Some(ids[0]), display_name: "Legacy person".into(), email: Some("LEGACY@example.com".into()), country: Some("CY".into()), }); let original = serde_json::to_string(&doc).unwrap(); db.raw_with( "INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now())", &[&workspace.to_string(), &original], ) .await .unwrap(); db.raw_with("INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) VALUES($1,$2,$3,'[\"view\"]',true)",&[&Uuid::new_v4().to_string(),&workspace.to_string(),&ids[0].to_string()]).await.unwrap(); db.raw_with("INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,1,$3,now())",&[&format!("{workspace}:1"),&workspace.to_string(),&original]).await.unwrap(); let missing = migrations::m_0011_account_people::migrate(&db, "{}") .await .unwrap_err() .to_string(); assert!(missing.contains("legacy-b"), "{missing}"); db.raw("DO $$ BEGIN IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name='meguri__users' AND column_name='email') THEN RAISE EXCEPTION 'partial migration'; END IF; END $$").await.unwrap(); assert!( migrations::m_0011_account_people::migrate(&db, r#"{"legacy-b":"legacy@example.com"}"#) .await .is_err() ); migrations::m_0011_account_people::migrate( &db, r#"{"legacy-b":" Second'$migration$@example.com "}"#, ) .await .unwrap(); let rows = UserRow::objects().all(&db).await.unwrap(); assert_eq!(rows.len(), 2); assert_eq!( rows.iter() .find(|u| u.id == ids[0].to_string()) .unwrap() .email, "legacy@example.com" ); assert_eq!( rows.iter() .find(|u| u.id == ids[1].to_string()) .unwrap() .email, "second'$migration$@example.com" ); let store = Store { db }; let state = store.workspace(workspace).await.unwrap(); assert_eq!(state.revision, 2); assert_eq!(state.document.people[0].id, ids[0]); assert_eq!( state.document.people[0].email.as_deref(), Some("legacy@example.com") ); let history_key = format!("{workspace}:1"); let history = query!(HistoryRow,$id==history_key) .get(&store.db) .await .unwrap() .unwrap(); assert_eq!(history.document, original); assert!( store .db .raw_with( "UPDATE meguri__users SET email=NULL WHERE id=$1", &[&ids[0].to_string()] ) .await .is_err() ); drop(store); admin .raw(&format!("DROP DATABASE {name} WITH (FORCE)")) .await .unwrap(); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn duplicate_contact_account_repair_preserves_current_references_drafts_and_history() { use cot::db::Database; use meguri::{ migrations, models::{WorkspaceDraftRow, WorkspaceMemberRow}, store::Store, }; let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()) .await .unwrap(); let name = format!("meguri_contact_link_{}", 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 db = Database::new(url.as_str()).await.unwrap(); cot::db::migrations::MigrationEngine::new(migrations::MIGRATIONS[..10].iter().copied()) .unwrap() .run(&db) .await .unwrap(); let account = Uuid::new_v4(); let contact = Uuid::new_v4(); let workspace = Uuid::new_v4(); let schedule = Uuid::new_v4(); db.raw_with("INSERT INTO meguri__users(id,username,display_name,country,password_hash,created_at) VALUES($1,'admin','Admin','CY','unusable',now())",&[&account.to_string()]).await.unwrap(); let mut doc = WorkspaceDocument::new("Existing".into()); doc.people.push(Person { id: contact, user_id: None, display_name: "Contact".into(), email: Some("admin@example.com".into()), country: Some("CY".into()), }); doc.schedules.push(Schedule { id: schedule, name: contact.to_string(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![contact], anchor: "2026-09-28".parse().unwrap(), shift_days: 7, }, }); doc.absences.push(Absence { id: Uuid::new_v4(), user_id: contact, start: "2027-01-01".parse().unwrap(), end: "2027-01-02".parse().unwrap(), }); doc.integration = Integration::Jsm { cloud_id: Uuid::new_v4().to_string(), team_id: Uuid::new_v4().to_string(), advanced_escalations: false, users: [(contact, "atlassian-id".into())].into(), }; doc.escalations.push(Escalation { id: Uuid::new_v4(), name: "Contact escalation".into(), steps: vec![EscalationStep { delay_minutes: 0, condition: EscalationCondition::IfNotAcked, recipient: Recipient::User { id: contact }, }], repeat: Default::default(), }); doc.generation.variables=serde_json::from_value(json!([ {"id":Uuid::new_v4(),"name":"people","node":{"id":Uuid::new_v4(),"kind":"list","entries":[{"id":Uuid::new_v4(),"kind":"person","value":contact}]}}, {"id":Uuid::new_v4(),"name":"text","node":{"id":Uuid::new_v4(),"kind":"text","value":contact.to_string()}} ])).unwrap(); doc.generation.rules=serde_json::from_value(json!([{"id":Uuid::new_v4(),"name":"Rule","enabled":true,"blocks":[{"id":Uuid::new_v4(),"kind":"rotation","fields":{"name":{"kind":"literal","value":contact.to_string()},"participants":{"kind":"literal","value":[contact]}}}]}])).unwrap(); let original = serde_json::to_string(&doc).unwrap(); let draft = meguri::generation_service::Draft { revision: 1, shared_revision: Some(4), variables: doc.generation.variables.clone(), rules: doc.generation.rules.clone(), route_position: 0, review_id: Some("stale-review".into()), }; db.raw_with( "INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now())", &[&workspace.to_string(), &original], ) .await .unwrap(); db.raw_with("INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) VALUES($1,$2,$3,'[\"view\"]',true)",&[&Uuid::new_v4().to_string(),&workspace.to_string(),&account.to_string()]).await.unwrap(); db.raw_with("INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,1,$3,now())",&[&format!("{workspace}:1"),&workspace.to_string(),&original]).await.unwrap(); db.raw_with( "INSERT INTO meguri__workspace_drafts(workspace_id,revision,payload) VALUES($1,4,$2)", &[ &workspace.to_string(), &serde_json::to_string(&draft).unwrap(), ], ) .await .unwrap(); db.raw_with("INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,published_revision,state,attempts,next_attempt,published_document,authorized_document,authorized_revision) VALUES($1,1,1,'synced',0,now(),$2,$2,1)",&[&workspace.to_string(),&original]).await.unwrap(); let mapping = r#"{"admin":"admin@example.com"}"#; assert!( migrations::m_0011_account_people::migrate(&db, mapping) .await .unwrap_err() .to_string() .contains("conflicts with an existing planning contact") ); let error = cot::db::migrations::MigrationEngine::new(migrations::MIGRATIONS.iter().copied()) .unwrap() .run(&db) .await .unwrap_err() .to_string(); assert!(error.contains("Set MEGURI_ACCOUNT_EMAILS"), "{error}"); assert!( migrations::m_0010b_contact_accounts::repair(&db, "{}") .await .is_err() ); migrations::m_0010b_contact_accounts::repair(&db, mapping) .await .unwrap(); // Running the real engine covers ordering of the new preparatory migration // relative to the unchanged email migration and records both normally. cot::db::migrations::MigrationEngine::new(migrations::MIGRATIONS.iter().copied()) .unwrap() .run(&db) .await .unwrap(); let store = Store { db }; let state = store.workspace(workspace).await.unwrap(); assert_eq!(state.revision, 3); assert_eq!(state.document.people.len(), 1); assert_eq!(state.document.people[0].id, account); assert_eq!(state.document.schedules[0].id, schedule); assert_eq!(state.document.schedules[0].name, contact.to_string()); if let ScheduleKind::Rotation { participants, .. } = &state.document.schedules[0].kind { assert_eq!(participants, &vec![account]); } else { panic!("rotation"); } assert_eq!(state.document.absences[0].user_id, account); assert!( matches!(state.document.escalations[0].steps[0].recipient,Recipient::User{id} if id==account) ); assert_eq!( state.document.generation.variables[0].node.entries[0].value, json!(account) ); assert_eq!( state.document.generation.variables[1].node.value, json!(contact) ); if let Integration::Jsm { users, .. } = &state.document.integration { assert_eq!(users.get(&account).unwrap(), "atlassian-id"); assert!(!users.contains_key(&contact)); } let key = workspace.to_string(); let saved = query!(WorkspaceDraftRow,$workspace_id==key.clone()) .get(&store.db) .await .unwrap() .unwrap(); assert_eq!(saved.revision, 5); let payload: serde_json::Value = serde_json::from_str(&saved.payload).unwrap(); assert_eq!( payload["variables"][0]["node"]["entries"][0]["value"], json!(account) ); assert_eq!( payload["rules"][0]["blocks"][0]["fields"]["participants"]["value"], json!([account]) ); assert_eq!( payload["rules"][0]["blocks"][0]["fields"]["name"]["value"], json!(contact) ); assert!(payload["review_id"].is_null()); let job = query!(SyncJobRow,$workspace_id==key.clone()) .get(&store.db) .await .unwrap() .unwrap(); assert_eq!(job.published_document.as_deref(), Some(original.as_str())); assert_eq!(job.authorized_document.as_deref(), Some(original.as_str())); assert_eq!(job.state, "synced"); let history_key = format!("{workspace}:1"); assert_eq!( query!(HistoryRow,$id==history_key) .get(&store.db) .await .unwrap() .unwrap() .document, original ); assert_eq!( query!(WorkspaceMemberRow,$workspace_id==key) .all(&store.db) .await .unwrap() .len(), 1 ); drop(store); admin .raw(&format!("DROP DATABASE {name} WITH (FORCE)")) .await .unwrap(); }