#![allow(clippy::await_holding_lock)]
use std::time::Duration;
use akribes_sdk::models::{ClaimOutcome, ConversionStatus};
use akribes_sdk::{AkribesClient, AkribesError};
use mockito::Server;
fn make_client(server: &Server) -> AkribesClient {
AkribesClient::builder(server.url())
.project_id(1)
.name("test-docs")
.id("test-id")
.build()
}
#[tokio::test]
async fn claim_hit() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/projects/1/documents/claim")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"content_hash": "aa".repeat(32),
"filename": "r.pdf",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_xyz","filename":"r.pdf","content_hash":"aa00","conversion_status":"ready"}"#,
)
.create_async()
.await;
let client = make_client(&server);
let outcome = client
.project(1)
.documents()
.claim(&"aa".repeat(32), "r.pdf")
.await
.unwrap();
match outcome {
ClaimOutcome::Hit(up) => {
assert_eq!(up.document_id, "doc_xyz");
assert_eq!(up.filename, "r.pdf");
assert_eq!(up.content_hash, "aa00");
assert_eq!(up.conversion_status, ConversionStatus::Ready);
}
ClaimOutcome::Miss => panic!("expected Hit"),
}
}
#[tokio::test]
async fn claim_miss() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"miss"}"#)
.create_async()
.await;
let client = make_client(&server);
let outcome = client
.project(1)
.documents()
.claim(&"bb".repeat(32), "r.pdf")
.await
.unwrap();
assert!(matches!(outcome, ClaimOutcome::Miss));
}
#[tokio::test]
async fn upload_returns_result() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/projects/1/documents")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"document_id":"doc_abc","filename":"r.pdf","content_hash":"aa00","conversion_status":"ready"}"#,
)
.create_async()
.await;
let client = make_client(&server);
let result = client
.project(1)
.documents()
.upload("r.pdf", b"fake-pdf-bytes".to_vec())
.await
.unwrap();
assert_eq!(result.document_id, "doc_abc");
assert_eq!(result.filename, "r.pdf");
assert_eq!(result.conversion_status, ConversionStatus::Ready);
}
#[tokio::test]
async fn ingest_hit_skips_upload() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_reused","filename":"r.txt","content_hash":"cc00","conversion_status":"text"}"#,
)
.create_async()
.await;
let client = make_client(&server);
let result = client
.project(1)
.documents()
.ingest("r.txt", b"hello".to_vec())
.await
.unwrap();
assert_eq!(result.document_id, "doc_reused");
assert_eq!(result.conversion_status, ConversionStatus::Text);
}
#[tokio::test]
async fn ingest_miss_falls_back_to_upload() {
let mut server = Server::new_async().await;
let _claim = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"miss"}"#)
.create_async()
.await;
let _upload = server
.mock("POST", "/projects/1/documents")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"document_id":"doc_new","filename":"r.pdf","content_hash":"aa00","conversion_status":"ready"}"#)
.create_async()
.await;
let client = make_client(&server);
let result = client
.project(1)
.documents()
.ingest("r.pdf", b"fresh-bytes".to_vec())
.await
.unwrap();
assert_eq!(result.document_id, "doc_new");
assert_eq!(result.conversion_status, ConversionStatus::Ready);
}
#[tokio::test]
async fn ingest_polls_on_converting_until_ready() {
let mut server = Server::new_async().await;
let _first = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_slow","filename":"big.pdf","content_hash":"dd00","conversion_status":"converting"}"#,
)
.expect(1)
.create_async()
.await;
let _second = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_slow","filename":"big.pdf","content_hash":"dd00","conversion_status":"ready"}"#,
)
.expect_at_least(1)
.create_async()
.await;
let client = make_client(&server);
let result = client
.project(1)
.documents()
.ingest("big.pdf", b"bytes".to_vec())
.await
.unwrap();
assert_eq!(result.document_id, "doc_slow");
assert_eq!(result.conversion_status, ConversionStatus::Ready);
}
#[tokio::test]
async fn ingest_surfaces_failed_as_error() {
let mut server = Server::new_async().await;
let _claim = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_bad","filename":"r.pdf","content_hash":"ee00","conversion_status":"failed"}"#,
)
.create_async()
.await;
let client = make_client(&server);
let err = client
.project(1)
.documents()
.ingest("r.pdf", b"bytes".to_vec())
.await
.expect_err("ingest should error on Failed status");
match err {
AkribesError::Other(msg) => {
assert!(
msg.contains("doc_bad"),
"error should mention doc id: {msg}"
);
assert!(
msg.contains("failed"),
"error should mention failure: {msg}"
);
}
other => panic!("expected AkribesError::Other, got {other:?}"),
}
}
#[tokio::test]
async fn ingest_miss_during_poll_falls_through_to_upload() {
let mut server = Server::new_async().await;
let _first = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_mid","filename":"r.pdf","content_hash":"ff00","conversion_status":"converting"}"#,
)
.expect(1)
.create_async()
.await;
let _miss = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"miss"}"#)
.expect_at_least(1)
.create_async()
.await;
let _upload = server
.mock("POST", "/projects/1/documents")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"document_id":"doc_new","filename":"r.pdf","content_hash":"aa00","conversion_status":"ready"}"#,
)
.expect(1)
.create_async()
.await;
let client = make_client(&server);
let result = client
.project(1)
.documents()
.ingest("r.pdf", b"bytes".to_vec())
.await
.unwrap();
assert_eq!(result.document_id, "doc_new");
assert_eq!(result.conversion_status, ConversionStatus::Ready);
}
fn env_timeout_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
async fn assert_ingest_timeout_error_says(client: &AkribesClient, expected_secs: u64) {
let err = client
.project(1)
.documents()
.ingest("big.pdf", b"bytes".to_vec())
.await
.expect_err("ingest should time out while server is stuck on Converting");
match err {
AkribesError::Transient { message, .. } => {
assert!(
message.contains(&format!("after {expected_secs}s")),
"error message should embed configured timeout ({expected_secs}s); got: {message}"
);
}
other => panic!("expected AkribesError::Transient, got {other:?}"),
}
}
#[test]
fn ingest_poll_timeout_default_when_nothing_set() {
let _guard = env_timeout_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS") };
let client = AkribesClient::builder("http://localhost:3001")
.project_id(1)
.build();
assert_eq!(
client.ingest_poll_timeout(),
Duration::from_secs(300),
"default ingest poll timeout should be 300 s when env+builder unset",
);
}
#[test]
fn ingest_poll_timeout_env_var_wins_over_default() {
let _guard = env_timeout_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS", "120") };
let client = AkribesClient::builder("http://localhost:3001")
.project_id(1)
.build();
assert_eq!(client.ingest_poll_timeout(), Duration::from_secs(120));
unsafe { std::env::remove_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS") };
}
#[test]
fn ingest_poll_timeout_builder_wins_over_env() {
let _guard = env_timeout_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS", "120") };
let client = AkribesClient::builder("http://localhost:3001")
.project_id(1)
.ingest_poll_timeout(Duration::from_secs(7))
.build();
assert_eq!(client.ingest_poll_timeout(), Duration::from_secs(7));
unsafe { std::env::remove_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS") };
}
#[test]
fn ingest_poll_timeout_env_var_zero_falls_back_to_default() {
let _guard = env_timeout_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS", "0") };
let client = AkribesClient::builder("http://localhost:3001").build();
assert_eq!(client.ingest_poll_timeout(), Duration::from_secs(300));
unsafe { std::env::set_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS", "not-a-number") };
let client = AkribesClient::builder("http://localhost:3001").build();
assert_eq!(client.ingest_poll_timeout(), Duration::from_secs(300));
unsafe { std::env::remove_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS") };
}
#[tokio::test]
async fn ingest_polls_until_configured_timeout_then_errors() {
let _guard = env_timeout_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("AKRIBES_SDK_INGEST_TIMEOUT_SECS") };
let mut server = Server::new_async().await;
let _converting = server
.mock("POST", "/projects/1/documents/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"hit","document_id":"doc_slow","filename":"big.pdf","content_hash":"dd00","conversion_status":"converting"}"#,
)
.expect_at_least(1)
.create_async()
.await;
let client = AkribesClient::builder(server.url())
.project_id(1)
.name("test-docs-timeout")
.id("test-id")
.ingest_poll_timeout(Duration::from_secs(1))
.build();
assert_ingest_timeout_error_says(&client, 1).await;
}