Files
meguri/tests/integration.rs
T
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

1697 lines
54 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"]
async fn generation_is_workspace_scoped_atomic_and_revision_checked() {
use meguri::{
demo,
generation_service::{self, Draft},
};
let mut f = Fixture::new().await;
let actor = f.people[0].id;
let id = f.team.id;
let (variables, rules) = demo::project_matrix(Utc::now().date_naive(), actor, f.people[1].id);
let mut draft = Draft {
revision: f.team.revision,
variables,
rules,
route_position: 0,
review_id: None,
};
let preview = generation_service::preview(&f.db, actor, id, draft.clone())
.await
.unwrap();
assert_eq!(preview["issues"], json!([]));
assert_eq!(f.db.workspace(id).await.unwrap().revision, draft.revision);
for kind in ["rotation", "priority", "escalation", "route"] {
assert!(
preview["result"]["managed"]
.as_array()
.unwrap()
.iter()
.any(|m| m["kind"] == kind)
);
}
assert!(
generation_service::apply(&f.db, actor, id, draft.clone())
.await
.is_err()
);
draft.review_id = preview["review_id"].as_str().map(str::to_owned);
let other = f
.extra_workspace("Independent workspace", &f.people[0])
.await;
assert!(
generation_service::preview(&f.db, f.people[1].id, other.id, draft.clone())
.await
.is_err()
);
let (a, b) = tokio::join!(
generation_service::apply(&f.db, actor, id, draft.clone()),
generation_service::apply(&f.db, actor, id, draft.clone())
);
assert_ne!(a.is_ok(), b.is_ok());
f.team = f.db.workspace(id).await.unwrap();
assert_eq!(f.team.revision, draft.revision + 1);
assert_eq!(f.team.document.generation.variables.len(), 3);
assert_eq!(
f.db.workspace(other.id).await.unwrap().revision,
other.revision
);
assert!(
f.db.workspace(other.id)
.await
.unwrap()
.document
.generation
.variables
.is_empty()
);
assert_eq!(
f.db.sync_status(id).await.unwrap()["requested_revision"],
f.team.revision
);
assert!(
f.db.audit(id)
.await
.unwrap()
.iter()
.any(|e| e["action"] == "generation.applied")
);
let mut stale = draft;
stale.revision = f.team.revision;
stale.rules[0].name = "Changed after review".into();
assert!(
generation_service::apply(&f.db, actor, id, stale)
.await
.is_err()
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn absence_preview_is_read_only_and_bulk_ranges_need_the_right_confirmations() {
use meguri::absence::{CoverRange, Draft};
let mut f = Fixture::new().await;
let owner = f.people[0].id;
let absent = f.people[1].id;
let second = f.people[2].id;
let team = f.team.id;
let mut doc = f.team.document.clone();
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
*participants = vec![absent];
}
doc.schedules.push(Schedule {
id: Uuid::new_v4(),
name: "Backup".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![absent],
anchor: "2026-09-21".parse().unwrap(),
shift_days: 7,
},
});
doc.schedules.push(Schedule {
id: Uuid::new_v4(),
name: "Project".into(),
handover: default_handover(),
kind: ScheduleKind::Priority {
participants: vec![absent, second],
fallback: f.schedule,
},
});
f.team = service::update_document(&f.db, owner, team, f.team.revision, doc)
.await
.unwrap();
let start = engine::schedule_duty_day(&f.team.document, f.schedule, f.start).unwrap();
let draft = Draft {
absence: Absence {
id: Uuid::new_v4(),
user_id: absent,
start,
end: start + Duration::days(13),
},
ranges: vec![
CoverRange {
local: None,
exact: None,
start,
end: start + Duration::days(6),
replacement: owner,
replace_projects: false,
},
CoverRange {
local: None,
exact: None,
start: start + Duration::days(7),
end: start + Duration::days(13),
replacement: second,
replace_projects: false,
},
],
};
let revision = f.team.revision;
let input = |force| service::NewAbsence {
revision,
draft: draft.clone(),
people: vec![],
force,
reason: "Vacation".into(),
};
let preview = service::preview_absence(&f.db, absent, team, input(false))
.await
.unwrap();
assert_eq!(preview["plan"]["schedules"], 3);
assert_eq!(preview["plan"]["uncovered"], 0);
assert_eq!(f.db.workspace(team).await.unwrap().revision, revision);
assert!(
service::create_absence(&f.db, absent, team, input(true))
.await
.is_err()
);
assert!(
service::preview_absence(&f.db, absent, Uuid::new_v4(), input(false))
.await
.is_err()
);
f.team = service::create_absence(&f.db, absent, team, input(false))
.await
.unwrap();
assert_eq!(f.team.revision, revision + 1);
assert_eq!(f.team.document.requests.len(), 2);
assert!(
f.team
.document
.requests
.iter()
.all(|r| r.status == RequestStatus::Pending && r.legs.len() == 2)
);
assert_eq!(
f.db.audit(team).await.unwrap()[0]["action"],
"absence.created"
);
assert_eq!(
f.db.sync_status(team).await.unwrap()["requested_revision"],
f.team.revision
);
assert!(matches!(
service::create_absence(&f.db, absent, team, input(false)).await,
Err(Error::Conflict(_))
));
let end = engine::schedule_handover(&f.team.document, f.schedule, start + Duration::days(14))
.unwrap();
let timeline = engine::timeline(&f.team.document, f.start, end).unwrap();
assert!(
providers::compile(&f.team, timeline, Utc::now()).is_err(),
"Pending coverage cannot publish an absent backup or rotation"
);
let first = f.team.document.requests[0].id;
assert!(
service::decide_request(&f.db, second, team, first, f.decision("approve"))
.await
.is_err()
);
f.team = service::decide_request(&f.db, owner, team, first, f.decision("approve"))
.await
.unwrap();
let next = f.team.document.requests[1].id;
f.team = service::decide_request(&f.db, second, team, next, f.decision("approve"))
.await
.unwrap();
let timeline = engine::timeline(&f.team.document, f.start, end).unwrap();
assert!(
timeline
.assignments
.iter()
.all(|a| !a.unavailable && a.user_id != absent)
);
assert!(providers::compile(&f.team, timeline, Utc::now()).is_ok());
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn schedule_removal_rewrites_dependencies_and_cancels_pending_requests_atomically() {
let mut f = Fixture::new().await;
let actor = f.people[0].id;
let team = f.team.id;
let secondary = Uuid::new_v4();
let mut doc = f.team.document.clone();
doc.schedules.push(Schedule {
id: secondary,
name: "Payments secondary".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![actor],
anchor: "2026-09-21".parse().unwrap(),
shift_days: 7,
},
});
doc.escalations.push(Escalation {
id: Uuid::new_v4(),
name: "Payments escalation".into(),
steps: [f.schedule, secondary]
.into_iter()
.enumerate()
.map(|(i, id)| EscalationStep {
delay_minutes: (i * 10) as u16,
condition: EscalationCondition::IfNotAcked,
recipient: Recipient::Schedule { id },
})
.collect(),
repeat: Default::default(),
});
f.team = service::update_document(&f.db, actor, team, f.team.revision, doc)
.await
.unwrap();
let mut leg = f.leg();
leg.schedule_id = secondary;
f.propose(vec![leg], false).await;
let revision = f.team.revision;
let input = || service::RemoveSchedule {
revision,
redirect_to: None,
};
assert!(matches!(
service::remove_schedule(&f.db, f.people[1].id, team, secondary, input()).await,
Err(Error::Forbidden)
));
assert!(
service::remove_schedule(&f.db, actor, Uuid::new_v4(), secondary, input())
.await
.is_err()
);
assert!(matches!(
service::remove_schedule(&f.db, actor, team, Uuid::new_v4(), input()).await,
Err(Error::NotFound)
));
let before = f.db.audit(team).await.unwrap().len();
f.team = service::remove_schedule(&f.db, actor, team, secondary, input())
.await
.unwrap();
assert_eq!(f.team.revision, revision + 1);
assert!(f.team.document.schedule(secondary).is_none());
assert_eq!(f.team.document.escalations[0].steps.len(), 1);
assert!(
matches!(f.team.document.escalations[0].steps[0].recipient, Recipient::Schedule { id } if id == f.schedule)
);
assert_eq!(f.team.document.requests[0].status, RequestStatus::Cancelled);
let events = f.db.audit(team).await.unwrap();
assert_eq!(events.len(), before + 1);
assert_eq!(events[0]["action"], "schedule.removed");
assert_eq!(
events[0]["details"]["impact"]["pending_requests"][0],
f.team.document.requests[0].id.to_string()
);
assert_eq!(
f.db.sync_status(team).await.unwrap()["requested_revision"],
f.team.revision
);
let key = team.to_string();
let history = query!(HistoryRow, $workspace_id == key)
.all(&f.db.db)
.await
.unwrap();
let old: WorkspaceDocument = serde_json::from_str(
&history
.iter()
.find(|h| h.revision == revision)
.unwrap()
.document,
)
.unwrap();
assert!(old.schedule(secondary).is_some());
assert_eq!(old.requests[0].status, RequestStatus::Pending);
assert!(matches!(
service::remove_schedule(&f.db, actor, team, f.schedule, input()).await,
Err(Error::Conflict(_))
));
let unchanged = f.team.revision;
assert!(
service::remove_schedule(
&f.db,
actor,
team,
f.schedule,
service::RemoveSchedule {
revision: unchanged,
redirect_to: None
}
)
.await
.is_err(),
"The last escalation step must not disappear"
);
assert_eq!(f.db.workspace(team).await.unwrap().revision, unchanged);
f.propose(vec![f.leg()], true).await;
let protected = f.team.revision;
assert!(
service::remove_schedule(
&f.db,
actor,
team,
f.schedule,
service::RemoveSchedule {
revision: protected,
redirect_to: None
}
)
.await
.is_err(),
"Approved future replacements protect the schedule"
);
assert_eq!(f.db.workspace(team).await.unwrap().revision, protected);
assert_eq!(
f.db.sync_status(team).await.unwrap()["requested_revision"],
protected
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn routing_order_is_scoped_revision_checked_and_published_as_one_change() {
let mut f = Fixture::new().await;
let actor = f.people[0].id;
let team = f.team.id;
let ids = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
let mut doc = f.team.document.clone();
doc.routes = ids
.iter()
.zip([0, 10, 9999])
.map(|(id, order)| RoutingRule {
id: *id,
name: format!("Rule {order}"),
order,
conditions: vec![RouteCondition {
field: "tags".into(),
operation: "contains".into(),
value: id.to_string(),
negate: false,
}],
window: None,
escalation_id: None,
})
.collect();
f.team = service::update_document(&f.db, actor, team, f.team.revision, doc)
.await
.unwrap();
let revision = f.team.revision;
let input = |ids: Vec<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()),
aliases: vec![],
});
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.push(id);
}
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: id,
start: f.start.date_naive() + Duration::days(100),
end: f.start.date_naive() + Duration::days(101),
});
f.team = service::update_document(&f.db, actor, team, f.team.revision, doc)
.await
.unwrap();
let contact = f.team.document.person(id).unwrap();
assert_eq!(contact.email.as_deref(), Some("new.person@example.com"));
assert_eq!(contact.country.as_deref(), Some("AM"));
let key = id.to_string();
assert!(
!query!(meguri::models::UserRow,$id==key)
.exists(&f.db.db)
.await
.unwrap()
);
assert!(f.db.require(team, id, Permission::View).await.is_err());
let mut forged = f.team.document.clone();
forged.people[0].user_id = Some(other.people[0].id);
assert!(
service::update_document(&f.db, actor, team, f.team.revision, forged)
.await
.is_err()
);
let mut duplicate = f.team.document.clone();
let mut person = duplicate.people[0].clone();
person.id = Uuid::new_v4();
duplicate.people.push(person);
assert!(
service::update_document(&f.db, actor, team, f.team.revision, duplicate)
.await
.is_err()
);
let csv = "country,date,name\nCY,2027-01-01,New Year\nAM,2027-01-01,New Year";
let version = f.team.revision;
let preview = meguri::holidays::compare(csv, &f.team.document.holidays).unwrap();
assert_eq!(preview.additions.len(), 2);
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
version,
"Preview is pure"
);
assert!(
service::import_holidays(&f.db, f.people[1].id, team, version, csv)
.await
.is_err()
);
assert!(
service::import_holidays(
&f.db,
actor,
team,
version,
"country,date,name\nXX,2027-01-01,Invalid\nCY,2027-01-02,Valid"
)
.await
.is_err()
);
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
version,
"Invalid CSV writes nothing"
);
f.team = service::import_holidays(&f.db, actor, team, version, csv)
.await
.unwrap();
assert_eq!(f.team.document.holidays.len(), 2);
assert!(matches!(
service::import_holidays(
&f.db,
actor,
team,
version,
"country,date,name\nCY,2027-12-25,Christmas"
)
.await,
Err(Error::Conflict(_))
));
f.team = service::import_holidays(
&f.db,
actor,
team,
f.team.revision,
"country,date,name\nCY,2027-01-01,Changed name\nCY,2027-12-25,Christmas",
)
.await
.unwrap();
assert_eq!(f.team.document.holidays[0].name, "New Year");
assert_eq!(f.team.document.holidays.len(), 3);
let audit = f.db.audit(team).await.unwrap();
assert!(audit.iter().any(|e| e["action"] == "holidays.imported"));
let job = f.db.sync_status(team).await.unwrap();
assert_eq!(job["requested_revision"], f.team.revision);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn workspace_interval_propagates_atomically_and_user_timezone_does_not_move_shifts() {
let mut f = Fixture::new().await;
let actor = f.people[0].id;
let tenant = f.team.id;
let team = f.team.id;
let other = f.extra_workspace("Another team", &f.people[0]).await;
let mut doc = f.team.document.clone();
doc.routes.push(RoutingRule {
id: Uuid::new_v4(),
name: "Working only".into(),
order: 1,
conditions: vec![],
window: Some(WorkingWindow::default()),
escalation_id: None,
});
f.team = service::update_document(&f.db, actor, team, f.team.revision, doc)
.await
.unwrap();
let revision = f.team.revision;
let settings_revision = f.team.revision;
let interval = WorkingWindow {
timezone: "Asia/Yerevan".into(),
weekdays: vec![1, 3, 5],
start: "09:00:00".parse().unwrap(),
end: "18:00:00".parse().unwrap(),
};
assert!(
service::set_working_interval(
&f.db,
f.people[1].id,
tenant,
settings_revision,
interval.clone()
)
.await
.is_err()
);
service::set_working_interval(&f.db, actor, team, settings_revision, interval.clone())
.await
.unwrap();
assert!(matches!(
service::set_working_interval(&f.db, actor, team, settings_revision, interval.clone())
.await,
Err(Error::Conflict(_))
));
f.team = f.db.workspace(team).await.unwrap();
assert_eq!(f.team.revision, revision + 1);
assert_eq!(f.team.document.timezone, "Asia/Yerevan");
assert_eq!(f.team.document.routes[0].window.as_ref(), Some(&interval));
assert_eq!(
f.db.workspace(other.id).await.unwrap().revision,
other.revision
);
assert_eq!(
f.db.sync_status(team).await.unwrap()["requested_revision"],
f.team.revision
);
let before = serde_json::to_value(
engine::timeline(&f.team.document, f.start, f.start + Duration::days(30)).unwrap(),
)
.unwrap();
let key = actor.to_string();
let mut user = query!(meguri::models::UserRow,$id==key.clone())
.get(&f.db.db)
.await
.unwrap()
.unwrap();
user.timezone = Some("America/New_York".into());
user.language = Some("fr".into());
service::save_profile(&f.db, &user).await.unwrap();
let unchanged = f.db.workspace(team).await.unwrap();
assert_eq!(
query!(meguri::models::UserRow,$id==key)
.get(&f.db.db)
.await
.unwrap()
.unwrap()
.language
.as_deref(),
Some("fr")
);
assert_eq!(unchanged.revision, f.team.revision);
assert_eq!(
serde_json::to_value(
engine::timeline(&unchanged.document, f.start, f.start + Duration::days(30)).unwrap()
)
.unwrap(),
before
);
user.country = "AM".into();
service::save_profile(&f.db, &user).await.unwrap();
let changed = f.db.workspace(team).await.unwrap();
assert_eq!(changed.revision, f.team.revision + 1);
assert_eq!(
changed.document.person(actor).unwrap().country.as_deref(),
Some("AM")
);
assert_eq!(changed.document.timezone, "Asia/Yerevan");
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn workspace_settings_and_configuration_share_one_revision() {
let f = Fixture::new().await;
let actor = f.people[0].id;
let id = f.team.id;
let a = WorkingWindow {
timezone: "Asia/Yerevan".into(),
..WorkingWindow::default()
};
let b = WorkingWindow {
timezone: "Asia/Tokyo".into(),
..WorkingWindow::default()
};
let (first, second) = tokio::join!(
service::set_working_interval(&f.db, actor, id, f.team.revision, a),
service::set_working_interval(&f.db, actor, id, f.team.revision, b)
);
assert_ne!(first.is_ok(), second.is_ok());
let current = f.db.workspace(id).await.unwrap();
assert_eq!(current.revision, f.team.revision + 1);
assert_eq!(
current.document.timezone,
current.document.working_interval.timezone
);
assert!(
service::update_document(&f.db, actor, id, f.team.revision, f.team.document.clone())
.await
.is_err()
);
assert!(
service::set_working_interval(
&f.db,
f.people[1].id,
id,
current.revision,
WorkingWindow::default()
)
.await
.is_err()
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn jsm_resolves_exact_active_email_before_writes_and_explicit_ids_override_lookup() {
let mut f = Fixture::new().await;
let server = fake_jsm::Server::start().await;
let contact = Uuid::new_v4();
f.team.document.people.push(Person {
id: contact,
user_id: None,
display_name: "Email contact".into(),
email: Some("new@example.com".into()),
country: Some("CY".into()),
aliases: vec![],
});
f.team.document.schedules[0].kind = ScheduleKind::Rotation {
participants: vec![contact],
anchor: f.start.date_naive(),
shift_days: 7,
};
f.team.document.integration = Integration::Jsm {
cloud_id: Uuid::new_v4().to_string(),
team_id: Uuid::new_v4().to_string(),
advanced_escalations: false,
users: Default::default(),
};
let plan = providers::compile(
&f.team,
engine::timeline(&f.team.document, f.start, f.start + Duration::days(7)).unwrap(),
f.start,
)
.unwrap();
let client = providers::jsm::Jsm::new(
server.base.clone(),
"admin@example.com".into(),
"fake".into(),
Uuid::new_v4().to_string(),
Default::default(),
)
.unwrap()
.with_directory_base(server.base.clone());
for users in [
vec![],
vec![json!({"active":true,"accountId":"hidden"})],
vec![json!({"active":false,"emailAddress":"new@example.com","accountId":"inactive"})],
vec![json!({"active":true,"emailAddress":"wrong@example.com","accountId":"wrong"})],
vec![
json!({"active":true,"emailAddress":"new@example.com","accountId":"a"}),
json!({"active":true,"emailAddress":"new@example.com","accountId":"b"}),
],
] {
server.state.lock().unwrap().users = users;
assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err());
assert_eq!(
server.state.lock().unwrap().writes,
0,
"No remote writes before identity is unambiguous"
);
}
server.state.lock().unwrap().users =
vec![json!({"active":true,"emailAddress":"NEW@example.com","accountId":"resolved-id"})];
client.publish(&f.db, &f.team, &plan, false).await.unwrap();
{
let state = server.state.lock().unwrap();
let schedule = state.objects.values().next().unwrap();
assert_eq!(
schedule["rotations"][0]["participants"][0]["id"],
"resolved-id"
);
}
let lookups = server.state.lock().unwrap().lookups;
let manual = providers::jsm::Jsm::new(
server.base.clone(),
"admin@example.com".into(),
"fake".into(),
Uuid::new_v4().to_string(),
[(contact, "resolved-id".into())].into(),
)
.unwrap();
manual.publish(&f.db, &f.team, &plan, true).await.unwrap();
assert_eq!(server.state.lock().unwrap().lookups, lookups);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn approval_swap_tenant_isolation_and_atomic_outbox() {
let mut f = Fixture::new().await;
let owner = f.people[0].id;
let bob = f.people[1].id;
let carol = f.people[2].id;
assert!(f.db.workspace(Uuid::new_v4()).await.is_err());
assert!(
f.db.require(f.team.id, Uuid::new_v4(), Permission::View)
.await
.is_err()
);
let other = f.extra_workspace("Private workspace", &f.people[0]).await;
assert!(f.db.require(other.id, bob, Permission::View).await.is_err());
assert!(matches!(
service::update_document(
&f.db,
bob,
f.team.id,
f.team.revision,
f.team.document.clone()
)
.await,
Err(Error::Forbidden)
));
assert!(
service::set_drift_policy(&f.db, bob, f.team.id, f.team.revision, true)
.await
.is_err()
);
let leg = f.leg();
let back = ReplacementLeg {
start: leg.start + Duration::days(7),
end: leg.end + Duration::days(7),
from_user: bob,
to_user: owner,
..leg.clone()
};
f.propose(vec![leg.clone(), back.clone()], false).await;
let request = f.team.document.requests.last().unwrap().id;
assert_eq!(
engine::timeline(&f.team.document, leg.start, leg.end)
.unwrap()
.assignments[0]
.user_id,
owner
);
assert!(
service::decide_request(&f.db, owner, f.team.id, request, f.decision("approve"))
.await
.is_err()
);
assert!(
service::decide_request(&f.db, carol, f.team.id, request, f.decision("force"))
.await
.is_err()
);
f.team = service::decide_request(&f.db, bob, f.team.id, request, f.decision("approve"))
.await
.unwrap();
assert_eq!(f.team.document.requests[0].status, RequestStatus::Applied);
assert_eq!(
engine::timeline(&f.team.document, leg.start, leg.end)
.unwrap()
.assignments[0]
.user_id,
bob
);
assert_eq!(
engine::timeline(&f.team.document, back.start, back.end)
.unwrap()
.assignments[0]
.user_id,
owner
);
assert!(
service::decide_request(&f.db, bob, f.team.id, request, f.decision("approve"))
.await
.is_err()
);
let audit = f.db.audit(f.team.id).await.unwrap();
assert_eq!(audit[0]["actor"], f.people[1].username);
assert_eq!(audit[0]["action"], "replacement.approve");
assert_eq!(
f.db.sync_status(f.team.id).await.unwrap()["requested_revision"],
f.team.revision
);
f.prioritize_publication().await;
let (a, b) = tokio::join!(worker::tick(&f.db), worker::tick(&f.db));
a.unwrap();
b.unwrap();
let status = f.db.sync_status(f.team.id).await.unwrap();
assert_eq!(status["state"], "synced");
assert_eq!(status["published_revision"], f.team.revision);
let events = f.db.audit(f.team.id).await.unwrap();
assert_eq!(
events
.iter()
.filter(|e| e["action"] == "publication.succeeded")
.count(),
1,
"Only one worker may publish a team"
);
assert_eq!(f.db.objects(f.team.id).await.unwrap().len(), 1);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() {
let f = Fixture::new().await;
let actor = f.people[0].id;
let team = f.team.id;
let mut left = f.team.document.clone();
left.name = "Left".into();
let mut right = left.clone();
right.name = "Right".into();
let (left, right) = tokio::join!(
service::update_document(&f.db, actor, team, f.team.revision, left),
service::update_document(&f.db, actor, team, f.team.revision, right)
);
assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1);
let record = f.db.workspace(team).await.unwrap();
assert_eq!(record.revision, f.team.revision + 1);
let before = f.db.audit(team).await.unwrap().len();
let mut invalid = record.clone();
invalid.document.name = "Must roll back".into();
// A history row that already owns the next revision's key makes the final statement fail
// after the workspace update and job upsert were evaluated: everything must roll back.
let planted = format!("{team}:{}", record.revision + 1);
f.db.db
.raw_with(
"INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,$3,'{}',now())",
&[&planted, &team.to_string(), &(record.revision + 1)],
)
.await
.unwrap();
assert!(
f.db.commit(
&mut invalid,
actor,
Permission::ManageMembers,
"test.invalid",
json!({}),
Some(Permission::member())
)
.await
.is_err()
);
f.db.db
.raw_with(
"DELETE FROM meguri__workspace_history WHERE id=$1",
&[&planted],
)
.await
.unwrap();
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
record.revision
);
assert_eq!(f.db.audit(team).await.unwrap().len(), before);
assert_eq!(
f.db.sync_status(team).await.unwrap()["requested_revision"],
record.revision
);
let id = team.to_string();
let rows = query!(HistoryRow,$workspace_id==id)
.all(&f.db.db)
.await
.unwrap();
assert_eq!(rows.len() as i64, record.revision);
// An old publication snapshot must not accidentally read revisions committed later.
let mut old = f.team.clone();
let mut changed = record.document.clone();
if let ScheduleKind::Rotation { participants, .. } = &mut changed.schedules[0].kind {
participants.swap(0, 1);
}
service::update_document(&f.db, actor, team, record.revision, changed)
.await
.unwrap();
let old_plan =
f.db.historical_timeline(&old, f.start, f.start + Duration::hours(1))
.await
.unwrap();
assert_eq!(old_plan.assignments[0].user_id, actor);
old = f.db.workspace(team).await.unwrap();
let new_plan =
f.db.historical_timeline(&old, f.start, f.start + Duration::hours(1))
.await
.unwrap();
assert_eq!(new_plan.assignments[0].user_id, f.people[1].id);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn volunteer_cover_preview_owner_approval_optional_comments_and_remaining_shift() {
let mut f = Fixture::new().await;
let [alice, bob, carol] = f.people.clone().map(|p| p.id);
let team = f.team.id;
let input = |revision, force, legs| service::NewRequest {
revision,
remaining: false,
reason: String::new(),
legs,
force,
};
let preview = service::preview_replacement(
&f.db,
bob,
team,
input(f.team.revision, false, vec![f.leg()]),
)
.await
.unwrap();
assert_eq!(preview["legs"][0]["to_user"], bob.to_string());
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
f.team.revision
);
assert!(
service::request_replacement(
&f.db,
carol,
team,
input(f.team.revision, false, vec![f.leg()])
)
.await
.is_err(),
"Unrelated members cannot transfer others' shifts"
);
assert!(
service::request_replacement(
&f.db,
bob,
team,
input(f.team.revision, true, vec![f.leg()])
)
.await
.is_err(),
"Volunteering grants no force permission"
);
f.team = service::request_replacement(
&f.db,
bob,
team,
input(f.team.revision, false, vec![f.leg()]),
)
.await
.unwrap();
let request = f.team.document.requests.last().unwrap();
assert_eq!(request.required_approvers, [alice].into());
assert_eq!(request.reason, "");
let id = request.id;
assert!(
service::decide_request(&f.db, bob, team, id, f.decision("approve"))
.await
.is_err()
);
f.team = service::decide_request(&f.db, alice, team, id, f.decision("approve"))
.await
.unwrap();
assert_eq!(
f.team.document.requests.last().unwrap().status,
RequestStatus::Applied
);
let mut next = f.leg();
next.start += Duration::days(1);
next.end += Duration::days(1);
f.team =
service::request_replacement(&f.db, alice, team, input(f.team.revision, true, vec![next]))
.await
.unwrap();
let forced = f.team.document.requests.last().unwrap();
assert_eq!(forced.force_reason.as_deref(), Some(""));
assert_eq!(forced.forced_by, Some(alice));
assert_eq!(forced.status, RequestStatus::Applied);
// Current shifts use the server clock. Approval clips the remaining interval, never history.
let mut doc = f.team.document.clone();
let current = Uuid::new_v4();
doc.schedules.push(Schedule {
id: current,
name: "Current duty".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![alice],
anchor: "2026-09-21".parse().unwrap(),
shift_days: 7,
},
});
f.team = service::update_document(&f.db, alice, team, f.team.revision, doc)
.await
.unwrap();
let mut current_input = input(
f.team.revision,
false,
vec![ReplacementLeg {
schedule_id: current,
start: Utc::now() - Duration::hours(1),
end: Utc::now() + Duration::hours(2),
from_user: alice,
to_user: bob,
}],
);
current_input.remaining = true;
current_input.reason = "OPS-123 · vacation".into();
let before = Utc::now();
f.team = service::request_replacement(&f.db, bob, team, current_input)
.await
.unwrap();
let request = f.team.document.requests.last().unwrap();
assert!(request.legs[0].start >= before);
let id = request.id;
let before_approval = Utc::now();
f.team = service::decide_request(&f.db, alice, team, id, f.decision("approve"))
.await
.unwrap();
let approved = f.team.document.requests.last().unwrap();
assert!(approved.legs[0].start >= before_approval);
assert_eq!(approved.reason, "OPS-123 · vacation");
assert!(
f.db.audit(team)
.await
.unwrap()
.iter()
.any(|e| e["action"] == "replacement.forced")
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn approval_revalidates_ownership_absence_and_overlap_and_force_is_audited() {
let mut f = Fixture::new().await;
let owner = f.people[0].id;
let bob = f.people[1].id;
let team = f.team.id;
let leg = f.leg();
f.propose(vec![leg.clone()], false).await;
let request = f.team.document.requests[0].id;
let mut doc = f.team.document.clone();
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: bob,
start: leg.start.date_naive(),
end: leg.start.date_naive(),
});
f.team = service::update_document(&f.db, owner, team, f.team.revision, doc)
.await
.unwrap();
assert!(
service::decide_request(&f.db, bob, team, request, f.decision("approve"))
.await
.is_err()
);
let mut doc = f.team.document.clone();
doc.absences.clear();
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.swap(0, 2);
}
f.team = service::update_document(&f.db, owner, team, f.team.revision, doc)
.await
.unwrap();
assert!(
service::decide_request(&f.db, bob, team, request, f.decision("approve"))
.await
.is_err()
);
let mut doc = f.team.document.clone();
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.swap(0, 2);
}
f.team = service::update_document(&f.db, owner, team, f.team.revision, doc)
.await
.unwrap();
f.propose(vec![leg], true).await;
assert!(
service::decide_request(&f.db, bob, team, request, f.decision("approve"))
.await
.is_err()
);
let applied = f.team.document.requests.last().unwrap();
assert_eq!(applied.forced_by, Some(owner));
assert_eq!(applied.status, RequestStatus::Applied);
assert!(
f.db.audit(team)
.await
.unwrap()
.iter()
.any(|e| e["action"] == "replacement.forced")
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn jsm_contract_retries_partial_writes_blocks_drift_and_retains_unmanaged_resources() {
let mut f = Fixture::new().await;
let server = fake_jsm::Server::start().await;
let remote_team = Uuid::new_v4().to_string();
let maps = f
.people
.iter()
.map(|p| (p.id, p.username.clone()))
.collect();
let client = providers::jsm::Jsm::new(
server.base.clone(),
"test@example.com".into(),
"test-token".into(),
remote_team.clone(),
maps,
)
.unwrap();
let policy = Uuid::new_v4();
f.team.document.escalations.push(Escalation {
id: policy,
name: "Escalation".into(),
steps: vec![EscalationStep {
delay_minutes: 0,
condition: EscalationCondition::IfNotAcked,
recipient: Recipient::Schedule { id: f.schedule },
}],
repeat: Default::default(),
});
f.team.document.routes.push(RoutingRule {
id: Uuid::new_v4(),
name: "Catch all".into(),
order: 9999,
conditions: vec![],
window: None,
escalation_id: Some(policy),
});
let plan = providers::compile(
&f.team,
engine::timeline(&f.team.document, f.start, f.start + Duration::days(20)).unwrap(),
f.start,
)
.unwrap();
server.state.lock().unwrap().fail_once = Some("/escalations".into());
assert!(client.publish(&f.db, &f.team, &plan, false).await.is_err());
assert_eq!(
f.db.objects(f.team.id).await.unwrap().len(),
1,
"Successful schedule write must be checkpointed before retry"
);
client.publish(&f.db, &f.team, &plan, false).await.unwrap();
let writes = server.state.lock().unwrap().writes;
client.publish(&f.db, &f.team, &plan, false).await.unwrap();
assert_eq!(
server.state.lock().unwrap().writes,
writes,
"Retry must not duplicate or rewrite resources"
);
let objects = f.db.objects(f.team.id).await.unwrap();
let schedule = objects
.iter()
.find(|o| o.object_key.starts_with("schedule:"))
.unwrap();
let path = format!("/schedules/{}", schedule.remote_id);
{
let state = server.state.lock().unwrap();
let value = &state.objects[&path];
assert_eq!(
value["rotations"][0]["participants"][0]["id"],
f.people[0].username
);
assert_eq!(
value["rotations"][1]["participants"][0]["id"],
f.people[1].username
);
let route = state
.objects
.iter()
.find(|(k, _)| k.contains("routing-rules/"))
.unwrap()
.1;
assert_eq!(route["order"], 0, "Translate priority to a JSM index");
assert_eq!(route["notify"]["type"], "escalation");
}
// Simulate a crash after remote creation, before the local checkpoint.
f.db.forget(f.team.id, &schedule.object_key).await.unwrap();
client.publish(&f.db, &f.team, &plan, false).await.unwrap();
assert_eq!(server.state.lock().unwrap().writes, writes);
server.state.lock().unwrap().objects.get_mut(&path).unwrap()["timezone"] = json!("Asia/Tokyo");
assert!(matches!(
client.publish(&f.db, &f.team, &plan, false).await,
Err(Error::Conflict(_))
));
assert_eq!(server.state.lock().unwrap().writes, writes);
client.publish(&f.db, &f.team, &plan, true).await.unwrap();
server.state.lock().unwrap().external_override = true;
assert!(matches!(
client.publish(&f.db, &f.team, &plan, false).await,
Err(Error::Conflict(_))
));
let mut empty = plan.clone();
empty.schedules.clear();
empty.escalations.clear();
empty.routes.clear();
assert!(matches!(
client.publish(&f.db, &f.team, &empty, false).await,
Err(Error::Conflict(_))
));
assert!(server.state.lock().unwrap().objects.contains_key(&path));
client.publish(&f.db, &f.team, &plan, true).await.unwrap();
server.state.lock().unwrap().objects.insert(
"/schedules/unmanaged".into(),
json!({"id":"unmanaged","name":"Do not touch"}),
);
client.publish(&f.db, &f.team, &empty, false).await.unwrap();
assert_eq!(server.state.lock().unwrap().objects.len(), 1);
assert!(
server
.state
.lock()
.unwrap()
.objects
.contains_key("/schedules/unmanaged")
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn failed_publication_is_durable_and_recoverable() {
let f = Fixture::new().await;
let mut doc = f.team.document.clone();
let day = engine::duty_day(&doc, Utc::now()).unwrap();
let actual = engine::timeline(&doc, Utc::now(), Utc::now() + Duration::hours(1))
.unwrap()
.assignments[0]
.user_id;
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: actual,
start: day,
end: day,
});
let record = service::update_document(&f.db, f.people[0].id, f.team.id, f.team.revision, doc)
.await
.unwrap();
f.prioritize_publication().await;
worker::tick(&f.db).await.unwrap();
let status = f.db.sync_status(f.team.id).await.unwrap();
assert_eq!(status["state"], "failed");
assert!(status["last_error"].as_str().unwrap().contains("absent"));
let mut doc = record.document.clone();
doc.absences.clear();
let record = service::update_document(&f.db, f.people[0].id, f.team.id, record.revision, doc)
.await
.unwrap();
f.prioritize_publication().await;
worker::tick(&f.db).await.unwrap();
assert_eq!(
f.db.sync_status(f.team.id).await.unwrap()["published_revision"],
record.revision
);
// A successful retry also leaves a durable audit event and clears the lease.
let id = f.team.id.to_string();
let job = query!(SyncJobRow,$workspace_id==id)
.get(&f.db.db)
.await
.unwrap()
.unwrap();
assert!(job.lease_token.is_none());
let id = f.team.id.to_string();
assert!(
query!(AuditRow,$workspace_id==id && $action=="publication.succeeded")
.exists(&f.db.db)
.await
.unwrap()
);
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn audit_resolves_historical_names_after_rename_and_keeps_raw_ids() {
let mut f = Fixture::new().await;
let leg = f.leg();
f.propose(vec![leg], true).await;
let schedule = f.schedule;
f.team.document.schedules[0].name = "Renamed schedule".into();
f.db.commit(
&mut f.team,
f.people[0].id,
Permission::EditSchedules,
"test.rename",
json!({"schedule_id":schedule}),
None,
)
.await
.unwrap();
let events = f.db.audit(f.team.id).await.unwrap();
let event = events
.iter()
.find(|e| e["details"].get("legs").is_some())
.unwrap();
assert_eq!(
event["resolved_details"]["legs"][0]["schedule_id"]["name"],
"General"
);
assert_eq!(
event["resolved_details"]["legs"][0]["from_user"]["name"],
"Alice"
);
assert_eq!(
event["resolved_details"]["legs"][0]["to_user"]["name"],
"Bob"
);
assert_eq!(event["details"]["legs"][0]["schedule_id"], json!(schedule));
assert_eq!(
events[0]["resolved_details"]["schedule_id"]["name"],
"Renamed schedule"
);
// Another tenant's identities are not part of this team's lookup context.
let other = Fixture::new().await;
let other_id = other.people[0].id;
f.db.commit(
&mut f.team,
f.people[0].id,
Permission::EditSchedules,
"test.unknown",
json!({"user_id":other_id}),
None,
)
.await
.unwrap();
let events = f.db.audit(f.team.id).await.unwrap();
assert_eq!(events[0]["resolved_details"]["user_id"], json!(other_id));
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn approved_cover_can_be_replaced_partially_with_approval_and_corrected_again() {
let mut f = Fixture::new().await;
let original = f.leg();
f.propose(vec![original.clone()], true).await;
let original_id = f.team.document.requests[0].id;
let correction = ReplacementLeg {
start: original.start + Duration::hours(3),
end: original.end - Duration::hours(3),
from_user: f.people[1].id,
to_user: f.people[2].id,
..original.clone()
};
f.team = service::request_replacement(
&f.db,
f.people[1].id,
f.team.id,
service::NewRequest {
remaining: false,
revision: f.team.revision,
reason: "Correct cover".into(),
legs: vec![correction.clone()],
force: false,
},
)
.await
.unwrap();
let pending = f.team.document.requests.last().unwrap().id;
assert!(f.team.document.requests[0].effective_legs.is_none());
assert_eq!(
engine::timeline(&f.team.document, original.start, original.end)
.unwrap()
.assignments[0]
.user_id,
f.people[1].id
);
// Ordinary members still cannot skip approval.
assert!(
service::decide_request(
&f.db,
f.people[1].id,
f.team.id,
pending,
f.decision("force")
)
.await
.is_err()
);
f.team = service::decide_request(
&f.db,
f.people[2].id,
f.team.id,
pending,
f.decision("approve"),
)
.await
.unwrap();
let old = &f.team.document.requests[0];
assert_eq!(old.id, original_id);
assert_eq!(old.legs[0].start, original.start);
assert_eq!(old.legs[0].end, original.end);
assert_eq!(old.effective_legs().len(), 2);
let plan = engine::timeline(&f.team.document, original.start, original.end).unwrap();
assert_eq!(
plan.assignments
.iter()
.map(|a| a.user_id)
.collect::<Vec<_>>(),
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
);
}