use super::*;
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::message::Part;
use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
async fn make_store() -> SqliteTaskStore {
SqliteTaskStore::new("sqlite::memory:")
.await
.expect("failed to create in-memory store")
}
fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new(ctx),
status: TaskStatus::new(state),
history: None,
artifacts: None,
metadata: None,
}
}
fn task_with_parts(parts: usize) -> Task {
let mut task = make_task("t-delta", "c-delta", TaskState::Working);
task.artifacts = Some(vec![Artifact::new(
"art",
(0..parts)
.map(|i| Part::text(format!("p{i}")))
.collect::<Vec<_>>(),
)]);
task
}
#[test]
fn append_batch_cutoff_is_exactly_max_inline_append() {
let task = task_with_parts(MAX_INLINE_APPEND);
let at = artifact_delta_sql(
&task,
ArtifactDelta::AppendedParts {
index: 0,
count: MAX_INLINE_APPEND,
},
)
.expect("no error");
assert!(
at.is_some(),
"a batch of exactly MAX_INLINE_APPEND must use the incremental path"
);
let task = task_with_parts(MAX_INLINE_APPEND + 1);
let over = artifact_delta_sql(
&task,
ArtifactDelta::AppendedParts {
index: 0,
count: MAX_INLINE_APPEND + 1,
},
)
.expect("no error");
assert!(
over.is_none(),
"a batch larger than MAX_INLINE_APPEND must fall back to a full save"
);
let task = task_with_parts(2);
let under = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 2 })
.expect("no error");
assert!(
under.is_some(),
"a small batch must use the incremental path"
);
}
#[test]
fn delta_claiming_all_parts_is_accepted_and_overclaiming_is_not() {
let task = task_with_parts(3);
let exact = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
.expect("no error");
assert!(
exact.is_some(),
"a delta covering every part of the artifact must be accepted"
);
let over = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 4 })
.expect("no error");
assert!(
over.is_none(),
"a delta claiming more parts than exist must fall back"
);
}
#[test]
fn zero_count_delta_falls_back() {
let task = task_with_parts(3);
let none = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 0 })
.expect("no error");
assert!(none.is_none(), "a zero-count delta must fall back");
}
#[test]
fn push_is_accepted_only_at_the_last_position() {
let mut task = make_task("t-push", "c-push", TaskState::Working);
task.artifacts = Some(vec![
Artifact::new("a0", vec![Part::text("x")]),
Artifact::new("a1", vec![Part::text("y")]),
]);
let last = artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 1 }).expect("no error");
assert!(
last.is_some(),
"pushing the artifact that is last in the vector must use the incremental path"
);
let not_last = artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 0 }).expect("no error");
assert!(
not_last.is_none(),
"pushing at any position but the last must fall back"
);
let mut single = make_task("t-one", "c-one", TaskState::Working);
single.artifacts = Some(vec![Artifact::new("only", vec![Part::text("z")])]);
let only = artifact_delta_sql(&single, ArtifactDelta::Pushed { index: 0 }).expect("no error");
assert!(
only.is_some(),
"the sole artifact is at the last position and must be accepted"
);
}
#[test]
fn task_without_artifacts_always_falls_back() {
let task = make_task("t-empty", "c-empty", TaskState::Working);
assert!(
artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.expect("no error")
.is_none()
);
assert!(
artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 0 })
.expect("no error")
.is_none()
);
}
#[tokio::test]
async fn list_honours_this_stores_own_page_size_cap() {
const STORED: usize = 60;
const CAP: u32 = 10;
const ASKED: u32 = 100;
let store = SqliteTaskStore::new("sqlite::memory:")
.await
.expect("open")
.with_max_page_size(CAP);
for i in 0..STORED {
store
.save(&make_task(&format!("t{i:03}"), "ctx", TaskState::Completed))
.await
.expect("save");
}
let page = store
.list(&ListTasksParams {
page_size: Some(ASKED),
..Default::default()
})
.await
.expect("list");
assert_eq!(
page.tasks.len(),
CAP as usize,
"a client asking for {ASKED} against a cap of {CAP} must get {CAP}, not \
whatever the store happens to hold"
);
}
#[tokio::test]
async fn save_and_get_round_trip() {
let store = make_store().await;
let task = make_task("t1", "ctx1", TaskState::Submitted);
store.save(&task).await.expect("save should succeed");
let retrieved = store
.get(&TaskId::new("t1"))
.await
.expect("get should succeed");
let retrieved = retrieved.expect("task should exist after save");
assert_eq!(retrieved.id, TaskId::new("t1"), "task id should match");
assert_eq!(
retrieved.context_id,
ContextId::new("ctx1"),
"context_id should match"
);
assert_eq!(
retrieved.status.state,
TaskState::Submitted,
"state should match"
);
}
#[tokio::test]
async fn get_returns_none_for_missing_task() {
let store = make_store().await;
let result = store
.get(&TaskId::new("nonexistent"))
.await
.expect("get should succeed");
assert!(
result.is_none(),
"get should return None for a missing task"
);
}
#[tokio::test]
async fn save_overwrites_existing_task() {
let store = make_store().await;
let task1 = make_task("t1", "ctx1", TaskState::Submitted);
store.save(&task1).await.expect("first save should succeed");
let task2 = make_task("t1", "ctx1", TaskState::Working);
store
.save(&task2)
.await
.expect("second save should succeed");
let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
retrieved.status.state,
TaskState::Working,
"state should be updated after overwrite"
);
}
#[tokio::test]
async fn insert_if_absent_returns_true_for_new_task() {
let store = make_store().await;
let task = make_task("t1", "ctx1", TaskState::Submitted);
let inserted = store
.insert_if_absent(&task)
.await
.expect("insert_if_absent should succeed");
assert!(
inserted,
"insert_if_absent should return true for a new task"
);
}
#[tokio::test]
async fn insert_if_absent_returns_false_for_existing_task() {
let store = make_store().await;
let task = make_task("t1", "ctx1", TaskState::Submitted);
store.save(&task).await.unwrap();
let duplicate = make_task("t1", "ctx1", TaskState::Working);
let inserted = store
.insert_if_absent(&duplicate)
.await
.expect("insert_if_absent should succeed");
assert!(
!inserted,
"insert_if_absent should return false for an existing task"
);
let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
retrieved.status.state,
TaskState::Submitted,
"original state should be preserved"
);
}
#[tokio::test]
async fn delete_removes_task() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.delete(&TaskId::new("t1"))
.await
.expect("delete should succeed");
let result = store.get(&TaskId::new("t1")).await.unwrap();
assert!(result.is_none(), "task should be gone after delete");
}
#[tokio::test]
async fn delete_nonexistent_is_ok() {
let store = make_store().await;
let result = store.delete(&TaskId::new("nonexistent")).await;
assert!(
result.is_ok(),
"deleting a nonexistent task should not error"
);
}
#[tokio::test]
async fn count_tracks_inserts_and_deletes() {
let store = make_store().await;
assert_eq!(
store.count().await.unwrap(),
0,
"empty store should have count 0"
);
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx1", TaskState::Working))
.await
.unwrap();
assert_eq!(
store.count().await.unwrap(),
2,
"count should be 2 after two saves"
);
store.delete(&TaskId::new("t1")).await.unwrap();
assert_eq!(
store.count().await.unwrap(),
1,
"count should be 1 after one delete"
);
}
#[tokio::test]
async fn list_all_tasks() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx2", TaskState::Working))
.await
.unwrap();
let params = ListTasksParams::default();
let response = store.list(¶ms).await.expect("list should succeed");
assert_eq!(response.tasks.len(), 2, "list should return all tasks");
}
#[tokio::test]
async fn list_filter_by_context_id() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx-a", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx-b", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t3", "ctx-a", TaskState::Working))
.await
.unwrap();
let params = ListTasksParams {
context_id: Some("ctx-a".to_string()),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(
response.tasks.len(),
2,
"should return only tasks with context_id ctx-a"
);
}
#[tokio::test]
async fn list_filter_by_status() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx1", TaskState::Working))
.await
.unwrap();
store
.save(&make_task("t3", "ctx1", TaskState::Working))
.await
.unwrap();
let params = ListTasksParams {
status: Some(TaskState::Working),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(response.tasks.len(), 2, "should return only Working tasks");
}
#[tokio::test]
async fn list_pagination() {
let store = make_store().await;
for i in 0..5 {
store
.save(&make_task(
&format!("task-{i:03}"),
"ctx1",
TaskState::Submitted,
))
.await
.unwrap();
}
let params = ListTasksParams {
page_size: Some(2),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
assert!(
!response.next_page_token.is_empty(),
"should have a next page token"
);
let params2 = ListTasksParams {
page_size: Some(2),
page_token: Some(response.next_page_token),
..Default::default()
};
let response2 = store.list(¶ms2).await.unwrap();
assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
assert!(
!response2.next_page_token.is_empty(),
"should still have a next page token"
);
let params3 = ListTasksParams {
page_size: Some(2),
page_token: Some(response2.next_page_token),
..Default::default()
};
let response3 = store.list(¶ms3).await.unwrap();
assert_eq!(response3.tasks.len(), 1, "last page should have 1 task");
assert!(
response3.next_page_token.is_empty(),
"last page should have no next page token"
);
}
#[tokio::test]
async fn list_orders_most_recently_updated_first() {
let store = make_store().await;
for id in ["c", "a", "b"] {
store
.save(&make_task(id, "ctx1", TaskState::Submitted))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(3)).await;
}
let response = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["b", "a", "c"],
"tasks should be ordered most-recently-updated first"
);
}
fn make_task_with_ts(id: &str, ctx: &str, state: TaskState, ts: &str) -> Task {
let mut task = make_task(id, ctx, state);
task.status.timestamp = Some(ts.to_owned());
task
}
#[tokio::test]
async fn list_orders_by_status_timestamp_not_write_order() {
let store = make_store().await;
for (id, ts) in [
("middle", "2026-01-02T00:00:00.000Z"),
("newest", "2026-01-03T00:00:00.000Z"),
("oldest", "2026-01-01T00:00:00.000Z"),
] {
store
.save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
.await
.unwrap();
}
let response = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newest", "middle", "oldest"],
"list must sort by status timestamp descending"
);
}
#[tokio::test]
async fn list_resave_without_status_change_keeps_position() {
let store = make_store().await;
store
.save(&make_task_with_ts(
"older",
"ctx1",
TaskState::Working,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"newer",
"ctx1",
TaskState::Working,
"2026-01-02T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"older",
"ctx1",
TaskState::Working,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
let response = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newer", "older"],
"a status-preserving re-save must not reorder the list"
);
}
#[tokio::test]
async fn list_filters_by_status_timestamp_after() {
let store = make_store().await;
for (id, ts) in [
("old", "2026-01-01T00:00:00.000Z"),
("boundary", "2026-01-02T00:00:00.000Z"),
("new", "2026-01-03T00:00:00.000Z"),
] {
store
.save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
.await
.unwrap();
}
let params = ListTasksParams {
status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["new"],
"filter must be strictly-after (boundary excluded)"
);
}
#[tokio::test]
async fn list_reorders_on_update() {
let store = make_store().await;
for id in ["t1", "t2", "t3"] {
store
.save(&make_task(id, "ctx1", TaskState::Submitted))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(3)).await;
}
store
.save(&make_task("t1", "ctx1", TaskState::Working))
.await
.unwrap();
let response = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["t1", "t3", "t2"],
"an updated task must move to the front of the update order"
);
}
#[tokio::test]
async fn list_pagination_visits_every_task_once() {
let store = make_store().await;
for i in 0..25 {
store
.save(&make_task(
&format!("t{i:03}"),
"ctx1",
TaskState::Submitted,
))
.await
.unwrap();
}
let mut seen = std::collections::HashSet::new();
let mut token: Option<String> = None;
loop {
let params = ListTasksParams {
page_size: Some(4),
page_token: token.clone(),
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
for t in &page.tasks {
assert!(seen.insert(t.id.0.clone()), "task {} seen twice", t.id.0);
}
if page.next_page_token.is_empty() {
break;
}
token = Some(page.next_page_token);
}
assert_eq!(seen.len(), 25, "every task must be visited exactly once");
}
#[tokio::test]
async fn list_malformed_page_token_returns_empty() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
let params = ListTasksParams {
page_token: Some("forged-cursor-no-separator".to_string()),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert!(
response.tasks.is_empty(),
"malformed page_token should yield empty results"
);
}
#[test]
fn to_a2a_error_formats_message() {
let sqlite_err = sqlx::Error::RowNotFound;
let a2a_err = to_a2a_error(sqlite_err);
let msg = format!("{a2a_err}");
assert!(
msg.contains("sqlite error"),
"error message should contain 'sqlite error': {msg}"
);
}
#[tokio::test]
async fn with_migrations_creates_store() {
let result = SqliteTaskStore::with_migrations("sqlite::memory:").await;
assert!(
result.is_ok(),
"with_migrations should succeed on a fresh database"
);
let store = result.unwrap();
let count = store.count().await.unwrap();
assert_eq!(count, 0, "freshly migrated store should be empty");
}
#[tokio::test]
async fn list_empty_store() {
let store = make_store().await;
let params = ListTasksParams::default();
let response = store.list(¶ms).await.unwrap();
assert!(
response.tasks.is_empty(),
"list on empty store should return no tasks"
);
assert!(
response.next_page_token.is_empty(),
"no pagination token for empty results"
);
}