2026-09-24 14:35:05 +03:00
#[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 (),
2026-10-07 15:30:31 +01:00
format! ( " {} @example.com" , Uuid ::new_v4 ()),
2026-09-24 14:35:05 +03:00
"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 (),
2026-10-07 15:30:31 +01:00
format! ( " {} @example.com" , Uuid ::new_v4 ()),
2026-09-24 14:35:05 +03:00
"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 {
2026-10-07 15:30:31 +01:00
shared_revision : None ,
2026-09-24 14:35:05 +03:00
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 \n CY,2027-01-01,New Year \n AM,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 \n XX,2027-01-01,Invalid \n CY,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 \n CY,2027-12-25,Christmas"
)
. await ,
Err ( Error ::Conflict ( _ ))
));
f . team = service ::import_holidays (
& f . db ,
actor ,
team ,
f . team . revision ,
"country,date,name \n CY,2027-01-01,Changed name \n CY,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 ;
2026-10-07 15:30:31 +01:00
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}" )}),
);
}
2026-09-24 14:35:05 +03:00
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 ),
});
2026-10-07 15:30:31 +01:00
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 ,
});
2026-09-24 14:35:05 +03:00
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! (
2026-10-07 15:30:31 +01:00
value [ "rotations" ][ 0 ][ "participants" ][ 1 ][ "id" ],
2026-09-24 14:35:05 +03:00
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" );
2026-10-07 15:30:31 +01:00
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:" )
);
2026-09-24 14:35:05 +03:00
}
2026-10-07 15:30:31 +01:00
{
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 );
2026-09-24 14:35:05 +03:00
// 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 ();
2026-10-07 15:30:31 +01:00
assert_eq! ( server . state . lock (). unwrap (). objects . len (), 52 );
2026-09-24 14:35:05 +03:00
assert! (
server
. state
. lock ()
. unwrap ()
. objects
. contains_key ( "/schedules/unmanaged" )
);
}
2026-10-07 15:30:31 +01:00
#[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:" )
);
}
2026-09-24 14:35:05 +03:00
#[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
);
}
2026-10-07 15:30:31 +01:00
#[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 ();
}