3737 lines
125 KiB
Rust
3737 lines
125 KiB
Rust
#[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<Uuid>| 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::<Vec<_>>(),
|
|
wanted
|
|
);
|
|
assert_eq!(
|
|
f.team
|
|
.document
|
|
.routes
|
|
.iter()
|
|
.map(|r| r.order)
|
|
.collect::<Vec<_>>(),
|
|
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::<chrono::DateTime<Utc>>()
|
|
.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::<chrono::DateTime<Utc>>()
|
|
.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::<chrono::DateTime<Utc>>().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::<chrono::DateTime<Utc>>()
|
|
.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::<chrono::DateTime<Utc>>()
|
|
.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<_>>(),
|
|
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::<chrono_tz::Tz>().unwrap(),
|
|
)
|
|
.naive_local(),
|
|
end: exact
|
|
.end
|
|
.with_timezone(
|
|
&f.team.document.timezone.parse::<chrono_tz::Tz>().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();
|
|
}
|