use std::ffi::{CStr, CString};
use serde_json::{json, Value};
use crate::{
syncular_client_close, syncular_client_command, syncular_client_new,
syncular_client_poll_event, syncular_free_string,
};
fn command(handle: *mut crate::Handle, method: &str, params: Value) -> Value {
let cmd = CString::new(json!({ "method": method, "params": params }).to_string()).unwrap();
let reply_ptr = syncular_client_command(handle, cmd.as_ptr());
assert!(!reply_ptr.is_null(), "command {method} returned null");
let text = unsafe { CStr::from_ptr(reply_ptr) }
.to_str()
.unwrap()
.to_owned();
syncular_free_string(reply_ptr);
serde_json::from_str(&text).unwrap()
}
fn simple_schema() -> Value {
json!({
"version": 1,
"tables": [{
"name": "todo",
"primaryKey": "id",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "title", "type": "string", "nullable": false },
{ "name": "done", "type": "boolean", "nullable": false }
],
"scopes": []
}]
})
}
#[test]
fn new_command_readrows_close_roundtrips() {
let config = CString::new("{}").unwrap();
let handle = syncular_client_new(config.as_ptr());
assert!(!handle.is_null());
let create = command(
handle,
"create",
json!({ "clientId": "c1", "schema": simple_schema() }),
);
assert_eq!(create["result"], json!({}), "create ok: {create}");
let sub = command(
handle,
"subscribe",
json!({ "id": "s1", "table": "todo", "scopes": {} }),
);
assert_eq!(sub["result"], json!({}));
let mutate = command(
handle,
"mutate",
json!({ "mutations": [{
"op": "upsert",
"table": "todo",
"values": { "id": "t1", "title": "hello", "done": false }
}] }),
);
assert!(
mutate["result"]["clientCommitId"].is_string(),
"mutate returns a commit id: {mutate}"
);
let rows = command(handle, "readRows", json!({ "table": "todo" }));
let list = rows["result"]["rows"].as_array().expect("rows array");
assert_eq!(list.len(), 1);
assert_eq!(list[0]["values"]["title"], "hello");
let pending = command(handle, "pendingCommitIds", Value::Null);
assert_eq!(pending["result"]["ids"].as_array().unwrap().len(), 1);
syncular_client_close(handle);
}
#[test]
fn sync_without_native_transport_fails_loud() {
let config = CString::new("{}").unwrap();
let handle = syncular_client_new(config.as_ptr());
command(
handle,
"create",
json!({ "clientId": "c1", "schema": simple_schema() }),
);
let outcome = command(handle, "sync", Value::Null);
assert_eq!(outcome["result"]["ok"], json!(false), "outcome: {outcome}");
assert_eq!(
outcome["result"]["errorCode"], "transport.unavailable",
"outcome: {outcome}"
);
syncular_client_close(handle);
}
#[test]
fn poll_event_nonblocking_returns_null_when_empty() {
let config = CString::new("{}").unwrap();
let handle = syncular_client_new(config.as_ptr());
command(
handle,
"create",
json!({ "clientId": "c1", "schema": simple_schema() }),
);
let ev = syncular_client_poll_event(handle, 0);
assert!(ev.is_null());
syncular_client_close(handle);
}
#[test]
fn malformed_config_returns_null_handle() {
let bad = CString::new("not json").unwrap();
let handle = syncular_client_new(bad.as_ptr());
assert!(handle.is_null());
}
#[test]
fn header_matches_symbols() {
use std::process::Command;
const EXPECTED: [&str; 5] = [
"syncular_client_new",
"syncular_client_command",
"syncular_client_poll_event",
"syncular_client_close",
"syncular_free_string",
];
let header = include_str!("../../../ffi.h");
for name in EXPECTED {
assert!(
header.contains(name),
"ffi.h is missing the exported symbol {name}"
);
}
let manifest = env!("CARGO_MANIFEST_DIR");
let mut candidates = Vec::new();
for profile in ["debug", "release"] {
for lib in ["libsyncular.a", "libsyncular.dylib", "libsyncular.so"] {
candidates.push(format!("{manifest}/../../target/{profile}/{lib}"));
}
}
let Some(artifact) = candidates
.into_iter()
.find(|p| std::path::Path::new(p).exists())
else {
eprintln!("skipping symbol check: no built libsyncular artifact found");
return;
};
let Ok(output) = Command::new("nm").arg(&artifact).output() else {
eprintln!("skipping symbol check: nm unavailable");
return;
};
let symbols = String::from_utf8_lossy(&output.stdout);
for name in EXPECTED {
let found = symbols
.lines()
.any(|l| l.ends_with(&format!(" {name}")) || l.ends_with(&format!(" _{name}")));
assert!(found, "exported symbol {name} not found in {artifact}");
}
}
#[test]
fn baseurl_without_native_feature_is_rejected() {
let config = CString::new(r#"{"baseUrl":"http://localhost:1/sync"}"#).unwrap();
let handle = syncular_client_new(config.as_ptr());
#[cfg(not(feature = "native-transport"))]
assert!(handle.is_null());
#[cfg(feature = "native-transport")]
{
assert!(!handle.is_null());
syncular_client_close(handle);
}
}