#![allow(deprecated)]
use assert_cmd::Command;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
fn temp_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("romm-cli-sync-it-{nanos}-{name}"));
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
fn write_manifest(dir: &std::path::Path, rows_json_fragment: &str) -> std::path::PathBuf {
let manifest_path = dir.join("manifest.json");
let body = format!(r#"{{"saves":[{}]}}"#, rows_json_fragment);
std::fs::write(&manifest_path, body).expect("write manifest");
manifest_path
}
#[tokio::test]
async fn sync_device_list_fails_early_when_openapi_lacks_save_sync_endpoints() {
let server = MockServer::start_async().await;
let _openapi = server
.mock_async(|when, then| {
when.method(GET).path("/openapi.json");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"openapi":"3.0.0","paths":{"/api/heartbeat":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#);
})
.await;
let devices = server
.mock_async(|when, then| {
when.method(GET).path("/api/devices");
then.status(200)
.header("content-type", "application/json")
.body("[]");
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args(["sync", "device", "list"]);
cmd.assert().failure().stderr(predicates::str::contains(
"This RomM server does not expose save-sync endpoints",
));
assert_eq!(devices.hits_async().await, 0);
}
#[tokio::test]
async fn sync_push_pull_fails_early_when_openapi_lacks_save_sync_endpoints() {
let server = MockServer::start_async().await;
let _openapi = server
.mock_async(|when, then| {
when.method(GET).path("/openapi.json");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"openapi":"3.0.0","paths":{}}"#);
})
.await;
let push_pull = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/devices/dev-1/push-pull");
then.status(200)
.header("content-type", "application/json")
.body("{}");
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args(["sync", "push-pull", "dev-1"]);
cmd.assert().failure().stderr(predicates::str::contains(
"This RomM server does not expose save-sync endpoints",
));
assert_eq!(push_pull.hits_async().await, 0);
}
#[tokio::test]
async fn sync_plan_prints_negotiate_response() {
let server = MockServer::start_async().await;
let work = temp_dir("plan");
let save_path = work.join("game.sav");
std::fs::write(&save_path, b"save-data").expect("write save");
let manifest = write_manifest(
&work,
r#"{"rom_id":7,"path":"game.sav","emulator":"retroarch"}"#,
);
let _m = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/negotiate");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session_id": 42,
"operations": [],
"total_upload": 0,
"total_download": 0,
"total_conflict": 0,
"total_no_op": 0
}"#,
);
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.arg("sync")
.arg("plan")
.arg("--device-id")
.arg("dev-1")
.arg("--manifest")
.arg(manifest);
cmd.assert()
.success()
.stdout(predicates::str::contains("\"session_id\": 42"));
}
#[tokio::test]
async fn sync_run_uploads_and_completes() {
let server = MockServer::start_async().await;
let work = temp_dir("run-upload");
let save_path = work.join("upload.sav");
std::fs::write(&save_path, b"upload-me").expect("write save");
let manifest = write_manifest(
&work,
r#"{"rom_id":7,"path":"upload.sav","file_name":"remote.sav","slot":"slot1","emulator":"retroarch"}"#,
);
let _negotiate = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/negotiate");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session_id": 11,
"operations": [{
"action":"upload",
"rom_id":7,
"save_id":null,
"file_name":"remote.sav",
"slot":"slot1",
"emulator":"retroarch",
"reason":"client newer",
"server_updated_at":null,
"server_content_hash":null
}],
"total_upload": 1,
"total_download": 0,
"total_conflict": 0,
"total_no_op": 0
}"#,
);
})
.await;
let upload = server
.mock_async(|when, then| {
when.method(POST)
.path("/api/saves")
.query_param("rom_id", "7")
.query_param("device_id", "dev-1")
.query_param("session_id", "11")
.query_param("slot", "slot1")
.query_param("emulator", "retroarch");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"id": 1}"#);
})
.await;
let complete = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/sessions/11/complete");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session": {
"id": 11,
"device_id": "dev-1",
"user_id": 1,
"status": "COMPLETED",
"initiated_at": "2026-01-01T00:00:00Z",
"completed_at": "2026-01-01T00:00:01Z",
"operations_planned": 1,
"operations_completed": 1,
"operations_failed": 0,
"error_message": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:01Z"
},
"play_session_ingest": null
}"#,
);
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args([
"sync",
"run",
"--device-id",
"dev-1",
"--manifest",
manifest.to_str().expect("manifest str"),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("uploaded=1"));
upload.assert();
complete.assert();
}
#[tokio::test]
async fn sync_run_downloads_file() {
let server = MockServer::start_async().await;
let work = temp_dir("run-download");
let download_dir = work.join("downloads");
std::fs::create_dir_all(&download_dir).expect("mkdir");
let manifest = write_manifest(&work, "");
let _negotiate = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/negotiate");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session_id": 12,
"operations": [{
"action":"download",
"rom_id":8,
"save_id":55,
"file_name":"from-server.sav",
"slot":null,
"emulator":null,
"reason":"server newer",
"server_updated_at":"2026-01-01T00:00:00Z",
"server_content_hash":"abc"
}],
"total_upload": 0,
"total_download": 1,
"total_conflict": 0,
"total_no_op": 0
}"#,
);
})
.await;
let _download = server
.mock_async(|when, then| {
when.method(GET)
.path("/api/saves/55/content")
.query_param("device_id", "dev-2")
.query_param("session_id", "12");
then.status(200).body("downloaded-bytes");
})
.await;
let complete = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/sessions/12/complete");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session": {
"id": 12,
"device_id": "dev-2",
"user_id": 1,
"status": "COMPLETED",
"initiated_at": "2026-01-01T00:00:00Z",
"completed_at": "2026-01-01T00:00:01Z",
"operations_planned": 1,
"operations_completed": 1,
"operations_failed": 0,
"error_message": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:01Z"
},
"play_session_ingest": null
}"#,
);
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args([
"sync",
"run",
"--device-id",
"dev-2",
"--manifest",
manifest.to_str().expect("manifest str"),
"--download-dir",
download_dir.to_str().expect("download dir str"),
]);
cmd.assert().success();
complete.assert();
let downloaded = download_dir.join("from-server.sav");
assert!(downloaded.exists(), "expected downloaded file");
}
#[tokio::test]
async fn sync_run_conflict_fails_by_default() {
let server = MockServer::start_async().await;
let work = temp_dir("run-conflict-fail");
let manifest = write_manifest(&work, "");
let _negotiate = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/negotiate");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session_id": 13,
"operations": [{
"action":"conflict",
"rom_id":9,
"save_id":2,
"file_name":"conflict.sav",
"slot":null,
"emulator":null,
"reason":"Both sides changed",
"server_updated_at":"2026-01-01T00:00:00Z",
"server_content_hash":"abc"
}],
"total_upload": 0,
"total_download": 0,
"total_conflict": 1,
"total_no_op": 0
}"#,
);
})
.await;
let complete = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/sessions/13/complete");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session": {
"id": 13,
"device_id": "dev-3",
"user_id": 1,
"status": "COMPLETED",
"initiated_at": "2026-01-01T00:00:00Z",
"completed_at": "2026-01-01T00:00:01Z",
"operations_planned": 1,
"operations_completed": 0,
"operations_failed": 1,
"error_message": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:01Z"
},
"play_session_ingest": null
}"#,
);
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args([
"sync",
"run",
"--device-id",
"dev-3",
"--manifest",
manifest.to_str().expect("manifest str"),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Conflict for rom_id=9"));
complete.assert();
}
#[tokio::test]
async fn sync_run_conflict_skip_succeeds() {
let server = MockServer::start_async().await;
let work = temp_dir("run-conflict-skip");
let manifest = write_manifest(&work, "");
let _negotiate = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/negotiate");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session_id": 14,
"operations": [{
"action":"conflict",
"rom_id":10,
"save_id":3,
"file_name":"conflict2.sav",
"slot":null,
"emulator":null,
"reason":"Both sides changed",
"server_updated_at":"2026-01-01T00:00:00Z",
"server_content_hash":"abc"
}],
"total_upload": 0,
"total_download": 0,
"total_conflict": 1,
"total_no_op": 0
}"#,
);
})
.await;
let complete = server
.mock_async(|when, then| {
when.method(POST).path("/api/sync/sessions/14/complete");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"session": {
"id": 14,
"device_id": "dev-4",
"user_id": 1,
"status": "COMPLETED",
"initiated_at": "2026-01-01T00:00:00Z",
"completed_at": "2026-01-01T00:00:01Z",
"operations_planned": 1,
"operations_completed": 0,
"operations_failed": 0,
"error_message": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:01Z"
},
"play_session_ingest": null
}"#,
);
})
.await;
let mut cmd = Command::cargo_bin("romm-cli").expect("binary");
cmd.env("API_BASE_URL", server.base_url())
.env("API_USE_HTTPS", "false")
.args([
"sync",
"run",
"--device-id",
"dev-4",
"--manifest",
manifest.to_str().expect("manifest str"),
"--conflict",
"skip",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("conflicts_skipped=1"));
complete.assert();
}