use axum::{
body::Body,
http::{header, Request, StatusCode},
routing::post,
Router,
};
use oxirs_core::rdf_store::ConcreteStore;
use oxirs_fuseki::handlers::patch::handle_patch;
use std::sync::Arc;
use tower::ServiceExt;
fn build_patch_router(store: Arc<ConcreteStore>) -> Router {
Router::new()
.route("/patch", post(handle_patch::<ConcreteStore>))
.with_state(store)
}
async fn do_patch(app: Router, patch_body: &str, graph: Option<&str>) -> (StatusCode, Vec<u8>) {
let uri = match graph {
Some(g) => format!("/patch?graph={}", oxirs_core::encoding::percent_encode(g)),
None => "/patch".to_string(),
};
let req = Request::builder()
.method("POST")
.uri(&uri)
.header(header::CONTENT_TYPE, "application/rdf-patch")
.body(Body::from(patch_body.to_string()))
.expect("request build");
let resp = app.oneshot(req).await.expect("oneshot");
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body bytes")
.to_vec();
(status, body_bytes)
}
fn parse_json(bytes: &[u8]) -> serde_json::Value {
serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null)
}
#[tokio::test]
async fn test_patch_simple_add() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = "A <http://example.org/alice> <http://example.org/name> \"Alice\" .\n";
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1);
assert_eq!(json["triples_deleted"], 0);
}
#[tokio::test]
async fn test_patch_simple_delete() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let setup_patch = "A <http://example.org/bob> <http://example.org/age> \"30\" .\n";
let (status, _) = do_patch(build_patch_router(store.clone()), setup_patch, None).await;
assert_eq!(status, StatusCode::OK);
let delete_patch = "D <http://example.org/bob> <http://example.org/age> \"30\" .\n";
let (status, body) = do_patch(build_patch_router(store), delete_patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_deleted"], 1);
}
#[tokio::test]
async fn test_patch_with_prefixes() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"PA foaf: <http://xmlns.com/foaf/0.1/>\n",
"A ex:alice foaf:name \"Alice\" .\n",
"A ex:bob foaf:name \"Bob\" .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 2);
}
#[tokio::test]
async fn test_patch_transaction_commit() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"TB .\n",
"A ex:s1 ex:p1 \"v1\" .\n",
"A ex:s2 ex:p2 \"v2\" .\n",
"A ex:s3 ex:p3 \"v3\" .\n",
"TC .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 3);
assert_eq!(json["transactions_committed"], 1);
}
#[tokio::test]
async fn test_patch_transaction_abort() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store.clone());
let patch = concat!(
"PA ex: <http://example.org/>\n",
"TB .\n",
"A ex:s1 ex:p1 \"v1\" .\n",
"A ex:s2 ex:p2 \"v2\" .\n",
"TA .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 0);
assert_eq!(json["transactions_aborted"], 1);
let setup_patch = "A <http://example.org/probe> <http://example.org/p> \"check\" .\n";
let (probe_status, probe_body) = do_patch(build_patch_router(store), setup_patch, None).await;
assert_eq!(probe_status, StatusCode::OK);
let probe_json = parse_json(&probe_body);
assert_eq!(probe_json["triples_added"], 1);
}
#[tokio::test]
async fn test_patch_multiple_transactions() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"TB .\n",
"A ex:s1 ex:p1 \"v1\" .\n",
"TC .\n",
"TB .\n",
"A ex:s2 ex:p2 \"v2\" .\n",
"TC .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["transactions_committed"], 2);
}
#[tokio::test]
async fn test_patch_headers() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"H id <urn:uuid:12345>\n",
"H prev <urn:uuid:previous>\n",
"H timestamp \"2024-01-01T00:00:00Z\"\n",
"A <http://example.org/test> <http://example.org/value> \"test\" .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1);
}
#[tokio::test]
async fn test_patch_prefix_delete() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"A ex:alice ex:name \"Alice\" .\n",
"PD ex:\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1);
}
#[tokio::test]
async fn test_patch_blank_nodes() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"A _:b1 ex:name \"Anonymous\" .\n",
"A ex:alice ex:knows _:b1 .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 2);
}
#[tokio::test]
async fn test_patch_statistics() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let setup = "A <http://example.org/s3> <http://example.org/p3> \"v3\" .\n";
do_patch(build_patch_router(store.clone()), setup, None).await;
let patch = concat!(
"PA ex: <http://example.org/>\n",
"TB .\n",
"A ex:s1 ex:p1 \"v1\" .\n",
"A ex:s2 ex:p2 \"v2\" .\n",
"D ex:s3 ex:p3 \"v3\" .\n",
"TC .\n",
);
let (status, body) = do_patch(build_patch_router(store), patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 2);
assert_eq!(json["triples_deleted"], 1);
assert_eq!(json["transactions_committed"], 1);
assert!(json["duration_ms"].as_u64().is_some());
}
#[tokio::test]
async fn test_patch_named_graph() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = "A <http://example.org/alice> <http://example.org/name> \"Alice\" .\n";
let (status, body) = do_patch(app, patch, Some("http://example.org/mygraph")).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["graph"], "http://example.org/mygraph");
assert_eq!(json["triples_added"], 1);
}
#[tokio::test]
async fn test_patch_malformed() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let malformed = "INVALID_OP <http://example.org/test>\n";
let (status, _body) = do_patch(app, malformed, None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_patch_incomplete_triple() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let incomplete = "A <http://example.org/alice> <http://example.org/name>\n";
let (status, _body) = do_patch(app, incomplete, None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_patch_transaction_error() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let invalid_tc = "TC .\n";
let (status, _body) = do_patch(app, invalid_tc, None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_patch_large() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let mut patch = String::from("PA ex: <http://example.org/>\nTB .\n");
for i in 0..1000 {
patch.push_str(&format!("A ex:s{} ex:p{} \"v{}\" .\n", i, i, i));
}
patch.push_str("TC .\n");
let (status, body) = do_patch(app, &patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1000);
assert_eq!(json["transactions_committed"], 1);
}
#[tokio::test]
async fn test_patch_sequential() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let patch1 = concat!(
"PA ex: <http://example.org/>\n",
"A ex:alice ex:age \"30\" .\n",
);
let (status, _) = do_patch(build_patch_router(store.clone()), patch1, None).await;
assert_eq!(status, StatusCode::OK);
let patch2 = concat!(
"PA ex: <http://example.org/>\n",
"D ex:alice ex:age \"30\" .\n",
"A ex:alice ex:age \"31\" .\n",
);
let (status, body) = do_patch(build_patch_router(store), patch2, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1);
assert_eq!(json["triples_deleted"], 1);
}
#[tokio::test]
async fn test_patch_concurrent() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let handles: Vec<_> = (0..10)
.map(|i| {
let s = store.clone();
tokio::spawn(async move {
let patch = format!(
"A <http://example.org/s{}> <http://example.org/p{}> \"v{}\" .\n",
i, i, i
);
let app = build_patch_router(s);
do_patch(app, &patch, None).await
})
})
.collect();
for handle in handles {
let (status, _) = handle.await.expect("join");
assert_eq!(status, StatusCode::OK);
}
}
#[tokio::test]
async fn test_patch_literal_language() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"A <http://example.org/alice> <http://example.org/name> \"Alice\" .\n",
"A <http://example.org/alice> <http://example.org/name> \"Alicia\" .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 2);
}
#[tokio::test]
async fn test_patch_literal_datatype() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"PA ex: <http://example.org/>\n",
"A ex:alice ex:name \"Alice\" .\n",
"A ex:bob ex:name \"Bob\" .\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 2);
}
#[tokio::test]
async fn test_patch_empty() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let (status, body) = do_patch(app, "", None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 0);
assert_eq!(json["triples_deleted"], 0);
}
#[tokio::test]
async fn test_patch_with_comments() {
let store = Arc::new(ConcreteStore::new().expect("store"));
let app = build_patch_router(store);
let patch = concat!(
"# This is a comment\n",
"PA ex: <http://example.org/>\n",
"# Another comment\n",
"A ex:alice ex:name \"Alice\" .\n",
"# Final comment\n",
);
let (status, body) = do_patch(app, patch, None).await;
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8_lossy(&body)
);
let json = parse_json(&body);
assert_eq!(json["triples_added"], 1);
}