#![cfg(feature = "sqlite")]
use a2a_protocol_server::push::{PushConfigStore, SqlitePushConfigStore};
use a2a_protocol_server::store::{SqliteTaskStore, TaskStore};
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::params::ListTasksParams;
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::task::{Task, TaskId, TaskState, TaskStatus};
fn make_task(id: &str, context_id: &str) -> Task {
Task {
id: TaskId(id.to_string()),
context_id: a2a_protocol_types::task::ContextId(context_id.to_string()),
status: TaskStatus::new(TaskState::Submitted),
artifacts: None,
history: None,
metadata: None,
}
}
async fn new_task_store() -> SqliteTaskStore {
SqliteTaskStore::new("sqlite::memory:")
.await
.expect("open in-memory sqlite")
}
#[tokio::test]
async fn task_save_and_get() -> A2aResult<()> {
let store = new_task_store().await;
let task = make_task("t1", "ctx1");
store.save(&task).await?;
let got = store.get(&TaskId("t1".into())).await?;
assert!(got.is_some());
assert_eq!(got.unwrap().id.0, "t1");
Ok(())
}
#[tokio::test]
async fn task_get_missing() -> A2aResult<()> {
let store = new_task_store().await;
let got = store.get(&TaskId("nope".into())).await?;
assert!(got.is_none());
Ok(())
}
#[tokio::test]
async fn task_save_upsert() -> A2aResult<()> {
let store = new_task_store().await;
let mut task = make_task("t1", "ctx1");
store.save(&task).await?;
task.status = TaskStatus::new(TaskState::Working);
store.save(&task).await?;
let got = store.get(&TaskId("t1".into())).await?.unwrap();
assert_eq!(got.status.state, TaskState::Working);
Ok(())
}
#[tokio::test]
async fn task_insert_if_absent() -> A2aResult<()> {
let store = new_task_store().await;
let task = make_task("t1", "ctx1");
assert!(store.insert_if_absent(&task).await?);
assert!(!store.insert_if_absent(&task).await?);
Ok(())
}
#[tokio::test]
async fn task_delete() -> A2aResult<()> {
let store = new_task_store().await;
store.save(&make_task("t1", "ctx1")).await?;
store.delete(&TaskId("t1".into())).await?;
assert!(store.get(&TaskId("t1".into())).await?.is_none());
Ok(())
}
#[tokio::test]
async fn task_list_basic() -> A2aResult<()> {
let store = new_task_store().await;
store.save(&make_task("a", "ctx1")).await?;
store.save(&make_task("b", "ctx1")).await?;
store.save(&make_task("c", "ctx2")).await?;
let all = store.list(&ListTasksParams::default()).await?;
assert_eq!(all.tasks.len(), 3);
let filtered = store
.list(&ListTasksParams {
context_id: Some("ctx1".into()),
..Default::default()
})
.await?;
assert_eq!(filtered.tasks.len(), 2);
Ok(())
}
#[tokio::test]
async fn task_list_pagination() -> A2aResult<()> {
let store = new_task_store().await;
for i in 0..5 {
store.save(&make_task(&format!("t{i:02}"), "ctx")).await?;
}
let page1 = store
.list(&ListTasksParams {
page_size: Some(2),
..Default::default()
})
.await?;
assert_eq!(page1.tasks.len(), 2);
assert!(!page1.next_page_token.is_empty());
let page2 = store
.list(&ListTasksParams {
page_size: Some(2),
page_token: Some(page1.next_page_token),
..Default::default()
})
.await?;
assert_eq!(page2.tasks.len(), 2);
Ok(())
}
async fn new_push_store() -> SqlitePushConfigStore {
SqlitePushConfigStore::new("sqlite::memory:")
.await
.expect("open in-memory sqlite")
}
fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
task_id: Some(task_id.to_string()),
id: None,
tenant: None,
url: "https://example.com/push".to_string(),
token: Some("tok".to_string()),
authentication: None,
}
}
#[tokio::test]
async fn push_set_and_get() -> A2aResult<()> {
let store = new_push_store().await;
let config = store.set(make_push_config("t1")).await?;
let id = config.id.as_deref().unwrap();
let got = store.get("t1", id).await?;
assert!(got.is_some());
assert_eq!(got.unwrap().task_id.as_deref(), Some("t1"));
Ok(())
}
#[tokio::test]
async fn push_get_missing() -> A2aResult<()> {
let store = new_push_store().await;
assert!(store.get("t1", "nope").await?.is_none());
Ok(())
}
#[tokio::test]
async fn push_list() -> A2aResult<()> {
let store = new_push_store().await;
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t2")).await?;
let t1_configs = store.list("t1").await?;
assert_eq!(t1_configs.len(), 2);
let t2_configs = store.list("t2").await?;
assert_eq!(t2_configs.len(), 1);
Ok(())
}
#[tokio::test]
async fn push_delete() -> A2aResult<()> {
let store = new_push_store().await;
let config = store.set(make_push_config("t1")).await?;
let id = config.id.as_deref().unwrap();
store.delete("t1", id).await?;
assert!(store.get("t1", id).await?.is_none());
Ok(())
}
#[tokio::test]
async fn push_upsert() -> A2aResult<()> {
let store = new_push_store().await;
let mut config = make_push_config("t1");
config.id = Some("fixed-id".into());
store.set(config.clone()).await?;
config.url = "https://example.com/v2".to_string();
store.set(config).await?;
let configs = store.list("t1").await?;
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].url, "https://example.com/v2");
Ok(())
}
fn task_with_history(id: &str, context_id: &str, messages: usize) -> Task {
use a2a_protocol_types::message::Message;
let mut task = make_task(id, context_id);
task.status = TaskStatus::with_timestamp(TaskState::Working);
task.history = Some(
(0..messages)
.map(|i| Message::user_text(format!("m-{i}"), format!("turn-{i}")))
.collect(),
);
task
}
#[tokio::test]
async fn a_status_delta_leaves_the_store_holding_what_a_save_would() -> A2aResult<()> {
let delta_store = new_task_store().await;
let save_store = new_task_store().await;
let initial = task_with_history("t-delta", "ctx", 12);
delta_store.save(&initial).await?;
save_store.save(&initial).await?;
let mut moved = initial.clone();
moved.status = TaskStatus::with_timestamp(TaskState::Completed);
delta_store.save_status_delta(&moved).await?;
save_store.save(&moved).await?;
let via_delta = delta_store.get(&TaskId::new("t-delta")).await?;
let via_save = save_store.get(&TaskId::new("t-delta")).await?;
assert_eq!(
via_delta, via_save,
"the delta path must leave the same record a full save would; history \
is the field a status-only rewrite is most likely to drop"
);
Ok(())
}
#[tokio::test]
async fn a_status_delta_updates_the_state_column_list_filters_on() -> A2aResult<()> {
let store = new_task_store().await;
let task = task_with_history("t-state", "ctx", 3);
store.save(&task).await?;
let mut done = task.clone();
done.status = TaskStatus::with_timestamp(TaskState::Completed);
store.save_status_delta(&done).await?;
let completed = store
.list(&ListTasksParams {
status: Some(TaskState::Completed),
..Default::default()
})
.await?;
assert_eq!(
completed.tasks.len(),
1,
"the state column still says Submitted/Working, so a filtered list \
cannot see the task the delta just completed"
);
assert_eq!(completed.tasks[0].id, TaskId::new("t-state"));
Ok(())
}
#[tokio::test]
async fn a_status_delta_moves_the_record_to_the_front_of_list_order() -> A2aResult<()> {
let store = new_task_store().await;
let older = task_with_history("t-older", "ctx", 2);
store.save(&older).await?;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let newer = task_with_history("t-newer", "ctx", 2);
store.save(&newer).await?;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let mut moved = older.clone();
moved.status = TaskStatus::with_timestamp(TaskState::Working);
store.save_status_delta(&moved).await?;
let listed = store.list(&ListTasksParams::default()).await?;
assert_eq!(
listed.tasks[0].id,
TaskId::new("t-older"),
"§3.1.4 orders by status timestamp, so a status change moves the \
record; a delta that skipped `updated_at` would leave it mis-ordered"
);
Ok(())
}
#[tokio::test]
async fn a_status_delta_for_an_absent_task_falls_back_to_a_save() -> A2aResult<()> {
let store = new_task_store().await;
let task = task_with_history("t-absent", "ctx", 1);
store.save_status_delta(&task).await?;
let stored = store.get(&TaskId::new("t-absent")).await?.expect(
"the fallback must have inserted it; dropping a transition is \
the one outcome worse than a slow one",
);
assert_eq!(stored.status.state, TaskState::Working);
Ok(())
}
#[tokio::test]
async fn a_status_delta_keeps_the_journal_rows_a_save_would_supersede() -> A2aResult<()> {
use a2a_protocol_server::store::ArtifactDelta;
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::message::Part;
let store = new_task_store().await;
let mut task = task_with_history("t-journal", "ctx", 2);
task.artifacts = Some(vec![Artifact::new("a", vec![Part::text("p0")])]);
store.save(&task).await?;
let mut grown = task.clone();
grown.artifacts = Some(vec![Artifact::new(
"a",
vec![Part::text("p0"), Part::text("p1")],
)]);
store
.save_artifact_delta(&grown, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await?;
let mut moved = grown.clone();
moved.status = TaskStatus::with_timestamp(TaskState::Completed);
store.save_status_delta(&moved).await?;
let stored = store.get(&TaskId::new("t-journal")).await?.expect("stored");
assert_eq!(
stored.artifacts.as_ref().expect("artifacts")[0].parts.len(),
2,
"the status delta deleted journal rows it had not superseded, so the \
appended part is gone"
);
assert_eq!(stored.status.state, TaskState::Completed);
Ok(())
}