Files
spacedrive/core/tests/library_join_test.rs
Jamie Pineandcapy-ai-staging[bot] 3234ab28a7 Run the two-core suites from the current test binary and wait on conditions instead of sleeping (#3149)
* Run subprocess suites from the current test binary and wait on conditions instead of sleeping

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Run subprocess suites from the current test binary and wait on conditions instead of sleeping

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Check marker content on every poll, take the event subscriber from the caller, and keep the receiver up until the sender's ack lands

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-09 07:17:33 +00:00

392 lines
11 KiB
Rust

//! Join an existing library from a second device over the real network stack.
//!
//! Alice owns a library with data. Bob pairs with Alice and runs
//! `library sync-setup --action join-remote`. This is the flow SPAC-12 ran
//! between Jamie's MacBook and the Mac Studio, and it checks the four things
//! that went wrong there: Bob backfills from Alice (not the other way round),
//! Alice's rows are untouched, Bob's copy of the library holds Alice's device
//! row, and once both sides are caught up no further catch-up runs.
use sd_core::infra::db::entities;
use sd_core::testing::CargoTestRunner;
use sd_core::Core;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use std::env;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
#[path = "helpers/wait.rs"]
mod wait;
use wait::{wait_for_file, wait_for_peer, wait_until};
const TEST_DIR: &str = "/tmp/spacedrive-library-join-test";
fn marker(name: &str) -> PathBuf {
Path::new(TEST_DIR).join(name)
}
fn write_marker(name: &str, content: &str) {
std::fs::write(marker(name), content).unwrap();
}
fn read_marker(name: &str) -> Option<String> {
std::fs::read_to_string(marker(name))
.ok()
.map(|s| s.trim().to_string())
}
async fn wait_for_marker(name: &str, max: Duration) -> String {
wait_for_file(marker(name), max)
.await
.unwrap_or_else(|e| panic!("{e}"))
}
fn fail(role: &str, message: String) -> ! {
write_marker(&format!("{}_error.txt", role), &message);
panic!("{}: {}", role, message);
}
/// Alice: owns the library, shares nothing explicitly, and must stay the
/// source of truth while Bob joins.
#[tokio::test]
#[ignore]
async fn alice_join_scenario() {
if env::var("TEST_ROLE").unwrap_or_default() != "alice" {
return;
}
env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR);
let mut core = timeout(
Duration::from_secs(10),
Core::new(PathBuf::from(TEST_DIR).join("alice")),
)
.await
.unwrap()
.unwrap();
core.device.set_name("Alice Device".to_string()).unwrap();
timeout(Duration::from_secs(10), core.init_networking())
.await
.unwrap()
.unwrap();
let library = core
.libraries
.create_library("Join Test Library".to_string(), None, core.context.clone())
.await
.unwrap();
let db = library.db().conn();
// Data that only Alice has: a second space next to the default one.
{
use chrono::Utc;
use sea_orm::{ActiveModelTrait, Set};
entities::space::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
uuid: Set(uuid::Uuid::new_v4()),
name: Set("Alice Projects".to_string()),
icon: Set("Folder".to_string()),
color: Set("#FF0000".to_string()),
order: Set(1),
created_at: Set(Utc::now().into()),
updated_at: Set(Utc::now().into()),
}
.insert(db)
.await
.unwrap();
}
let spaces_before = entities::space::Entity::find().all(db).await.unwrap();
assert_eq!(spaces_before.len(), 2);
let default_space_before = spaces_before
.iter()
.find(|s| s.name == "Default")
.cloned()
.expect("default space");
write_marker("library_id.txt", &library.id().to_string());
write_marker(
"alice_device_id.txt",
&core.device.device_id().unwrap().to_string(),
);
let networking = core.networking().expect("networking");
let (pairing_code, _) = timeout(
Duration::from_secs(15),
networking.start_pairing_as_initiator(false),
)
.await
.unwrap()
.unwrap();
write_marker("pairing_code.txt", &pairing_code);
// Wait for Bob to join the library: his device row lands in our table.
let own_device_id = core.device.device_id().unwrap();
let bob_device_id = match wait_until("Bob's device row", Duration::from_secs(90), || async {
let devices = entities::device::Entity::find().all(db).await.unwrap();
devices
.iter()
.find(|d| d.uuid != own_device_id)
.map(|bob| bob.uuid)
})
.await
{
Ok(id) => id,
Err(_) => fail("alice", "Bob never registered in Alice's library".into()),
};
println!("Alice: Bob {} joined the library", bob_device_id);
write_marker("alice_saw_bob.txt", &bob_device_id.to_string());
// Give sync time to settle on both sides, then check nothing moved.
wait_for_marker("bob_success.txt", Duration::from_secs(120)).await;
let sync = library.sync_service().expect("sync service");
let sessions_a = sync
.metrics()
.metrics()
.operations
.backfill_sessions_completed
.load(std::sync::atomic::Ordering::Relaxed);
// A fixed window is the only way to observe that nothing happens: the
// sync loop runs every 5 s, so 15 s covers three chances to catch up.
tokio::time::sleep(Duration::from_secs(15)).await;
let sessions_b = sync
.metrics()
.metrics()
.operations
.backfill_sessions_completed
.load(std::sync::atomic::Ordering::Relaxed);
let spaces_after = entities::space::Entity::find().all(db).await.unwrap();
let default_space_after = spaces_after
.iter()
.find(|s| s.name == "Default")
.cloned()
.expect("default space");
let mut problems = Vec::new();
if spaces_after.len() != 2 {
problems.push(format!(
"Alice's space count changed from 2 to {}",
spaces_after.len()
));
}
if default_space_after.updated_at != default_space_before.updated_at {
problems.push(format!(
"Alice's Default space was overwritten by sync (updated_at {} -> {})",
default_space_before.updated_at, default_space_after.updated_at
));
}
if sessions_b != sessions_a {
problems.push(format!(
"Alice kept catching up without new events ({} -> {} sessions in 15s)",
sessions_a, sessions_b
));
}
if sessions_a > 1 {
problems.push(format!(
"Alice ran {} backfill/catch-up sessions against an empty joiner",
sessions_a
));
}
if !problems.is_empty() {
fail("alice", problems.join("; "));
}
write_marker("alice_success.txt", "success");
println!("Alice: Test completed");
}
/// Bob: pairs, joins Alice's library, and must end up with her state.
#[tokio::test]
#[ignore]
async fn bob_join_scenario() {
if env::var("TEST_ROLE").unwrap_or_default() != "bob" {
return;
}
env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR);
let mut core = timeout(
Duration::from_secs(10),
Core::new(PathBuf::from(TEST_DIR).join("bob")),
)
.await
.unwrap()
.unwrap();
core.device.set_name("Bob Device".to_string()).unwrap();
timeout(Duration::from_secs(10), core.init_networking())
.await
.unwrap()
.unwrap();
let library_id: uuid::Uuid = wait_for_marker("library_id.txt", Duration::from_secs(30))
.await
.parse()
.unwrap();
let alice_device_id: uuid::Uuid =
wait_for_marker("alice_device_id.txt", Duration::from_secs(30))
.await
.parse()
.unwrap();
let pairing_code = wait_for_marker("pairing_code.txt", Duration::from_secs(30)).await;
let networking = core.networking().expect("networking");
timeout(
Duration::from_secs(15),
networking.start_pairing_as_joiner(&pairing_code, false),
)
.await
.unwrap()
.unwrap();
if wait_for_peer(&core, alice_device_id, Duration::from_secs(30))
.await
.is_err()
{
fail("bob", "pairing timeout".into());
}
println!("Bob: paired with Alice");
// Bob needs any local library to address the action; mirror the CLI.
let scratch = core
.libraries
.create_library("Bob Scratch".to_string(), None, core.context.clone())
.await
.unwrap();
use sd_core::infra::action::CoreAction;
use sd_core::ops::network::sync_setup::{
LibrarySyncAction, LibrarySyncSetupAction, LibrarySyncSetupInput,
};
let input = LibrarySyncSetupInput {
local_device_id: core.device.device_id().unwrap(),
remote_device_id: alice_device_id,
local_library_id: scratch.id(),
remote_library_id: Some(library_id),
action: LibrarySyncAction::JoinRemoteLibrary {
remote_library_id: library_id,
remote_library_name: "Join Test Library".to_string(),
},
leader_device_id: alice_device_id,
};
let started = tokio::time::Instant::now();
let output = match LibrarySyncSetupAction::from_input(input)
.unwrap()
.execute(core.context.clone())
.await
{
Ok(output) => output,
Err(e) => fail("bob", format!("join-remote failed: {:?}", e)),
};
println!(
"Bob: join-remote returned in {:?}: {:?}",
started.elapsed(),
output
);
if !output.success || !output.devices_registered {
fail(
"bob",
format!("join-remote reported failure: {}", output.message),
);
}
let library = match core.libraries.get_library(library_id).await {
Some(library) => library,
None => fail("bob", "joined library is not open locally".into()),
};
let db = library.db().conn();
// The join must leave Alice's device row in Bob's copy right away: the
// action reports devices_registered, so the row has to be there.
let alice_rows = entities::device::Entity::find()
.filter(entities::device::Column::Uuid.eq(alice_device_id))
.count(db)
.await
.unwrap();
if alice_rows != 1 {
fail(
"bob",
"join-remote returned devices_registered but Alice's device row is missing".into(),
);
}
// Backfill brings Alice's spaces across.
let space_names = || async {
entities::space::Entity::find()
.all(db)
.await
.unwrap()
.into_iter()
.map(|s| s.name)
.collect::<Vec<String>>()
};
match wait_until("Alice's spaces", Duration::from_secs(60), || async {
let names = space_names().await;
names.iter().any(|n| n == "Alice Projects").then_some(names)
})
.await
{
Ok(names) => println!("Bob: backfilled Alice's spaces: {:?}", names),
Err(_) => fail(
"bob",
format!(
"Bob never received Alice's spaces, has {:?}",
space_names().await
),
),
}
let sync = library.sync_service().expect("sync service");
if wait_until("Ready after backfill", Duration::from_secs(30), || async {
sync.peer_sync().state().await.is_ready().then_some(())
})
.await
.is_err()
{
fail("bob", "Bob never reached Ready after backfill".into());
}
write_marker("bob_success.txt", "success");
wait_for_marker("alice_success.txt", Duration::from_secs(60)).await;
println!("Bob: Test completed");
}
#[tokio::test]
async fn test_join_remote_library_backfills_joiner() {
let _ = std::fs::remove_dir_all(TEST_DIR);
std::fs::create_dir_all(TEST_DIR).unwrap();
let mut runner = CargoTestRunner::for_test_file("library_join_test")
.with_timeout(Duration::from_secs(240))
.add_subprocess("alice", "alice_join_scenario")
.add_subprocess("bob", "bob_join_scenario");
runner.spawn_single_process("alice").await.unwrap();
// Bob waits for the marker files himself, so he can start at once.
runner.spawn_single_process("bob").await.unwrap();
let result = runner
.wait_for_success(|_| {
for role in ["alice", "bob"] {
if let Some(error) = read_marker(&format!("{}_error.txt", role)) {
println!("{} failed: {}", role, error);
return false;
}
}
read_marker("alice_success.txt").is_some() && read_marker("bob_success.txt").is_some()
})
.await;
if let Err(e) = result {
for role in ["alice", "bob"] {
if let Some(error) = read_marker(&format!("{}_error.txt", role)) {
println!("{} error: {}", role, error);
}
}
for (name, output) in runner.get_all_outputs() {
println!("\n{} output:\n{}", name, output);
}
panic!("library join test failed: {}", e);
}
}