1use std::future::Future;
22use std::pin::Pin;
23
24use a2a_protocol_types::error::{A2aError, A2aResult};
25use a2a_protocol_types::params::ListTasksParams;
26use a2a_protocol_types::responses::TaskListResponse;
27use a2a_protocol_types::task::{Task, TaskId};
28use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
29
30use super::task_store::TaskStore;
31
32#[derive(Debug, Clone)]
56pub struct SqliteTaskStore {
57 pool: SqlitePool,
58}
59
60impl SqliteTaskStore {
61 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
67 let pool = sqlite_pool(url).await?;
68 Self::from_pool(pool).await
69 }
70
71 pub async fn with_migrations(url: &str) -> Result<Self, sqlx::Error> {
81 let pool = sqlite_pool(url).await?;
82
83 let runner = super::migration::MigrationRunner::new(pool.clone());
84 runner.run_pending().await?;
85
86 Ok(Self { pool })
87 }
88
89 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
95 sqlx::query(
96 "CREATE TABLE IF NOT EXISTS tasks (
97 id TEXT PRIMARY KEY,
98 context_id TEXT NOT NULL,
99 state TEXT NOT NULL,
100 data TEXT NOT NULL,
101 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
102 created_at TEXT NOT NULL DEFAULT (datetime('now'))
103 )",
104 )
105 .execute(&pool)
106 .await?;
107
108 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id)")
109 .execute(&pool)
110 .await?;
111
112 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state)")
113 .execute(&pool)
114 .await?;
115
116 sqlx::query(
117 "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state)",
118 )
119 .execute(&pool)
120 .await?;
121
122 sqlx::query(
125 "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC)",
126 )
127 .execute(&pool)
128 .await?;
129
130 Ok(Self { pool })
131 }
132}
133
134async fn sqlite_pool(url: &str) -> Result<SqlitePool, sqlx::Error> {
139 sqlite_pool_with_size(url, 8).await
140}
141
142async fn sqlite_pool_with_size(url: &str, max_connections: u32) -> Result<SqlitePool, sqlx::Error> {
144 use sqlx::sqlite::SqliteConnectOptions;
145 use std::str::FromStr;
146
147 let opts = SqliteConnectOptions::from_str(url)?
148 .pragma("journal_mode", "WAL")
149 .pragma("busy_timeout", "5000")
150 .pragma("synchronous", "NORMAL")
151 .pragma("foreign_keys", "ON")
152 .create_if_missing(true);
153
154 SqlitePoolOptions::new()
155 .max_connections(max_connections)
156 .connect_with(opts)
157 .await
158}
159
160#[allow(clippy::needless_pass_by_value)]
162fn to_a2a_error(e: sqlx::Error) -> A2aError {
163 A2aError::internal(format!("sqlite error: {e}"))
164}
165
166#[allow(clippy::manual_async_fn)]
167impl TaskStore for SqliteTaskStore {
168 fn save<'a>(
169 &'a self,
170 task: &'a Task,
171 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
172 Box::pin(async move {
173 let id = task.id.0.as_str();
174 let context_id = task.context_id.0.as_str();
175 let state = task.status.state.to_string();
176 let data = serde_json::to_string(task)
177 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
178 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
182
183 sqlx::query(
184 "INSERT INTO tasks (id, context_id, state, data, updated_at)
185 VALUES (?1, ?2, ?3, ?4, COALESCE(?5, strftime('%Y-%m-%d %H:%M:%f','now')))
186 ON CONFLICT(id) DO UPDATE SET
187 context_id = excluded.context_id,
188 state = excluded.state,
189 data = excluded.data,
190 updated_at = excluded.updated_at",
191 )
192 .bind(id)
193 .bind(context_id)
194 .bind(&state)
195 .bind(&data)
196 .bind(&status_ts)
197 .execute(&self.pool)
198 .await
199 .map_err(to_a2a_error)?;
200
201 Ok(())
202 })
203 }
204
205 fn get<'a>(
206 &'a self,
207 id: &'a TaskId,
208 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
209 Box::pin(async move {
210 let row: Option<(String,)> = sqlx::query_as("SELECT data FROM tasks WHERE id = ?1")
211 .bind(id.0.as_str())
212 .fetch_optional(&self.pool)
213 .await
214 .map_err(to_a2a_error)?;
215
216 match row {
217 Some((data,)) => {
218 let task: Task = serde_json::from_str(&data).map_err(|e| {
219 A2aError::internal(format!("failed to deserialize task: {e}"))
220 })?;
221 Ok(Some(task))
222 }
223 None => Ok(None),
224 }
225 })
226 }
227
228 #[allow(clippy::too_many_lines)]
229 fn list<'a>(
230 &'a self,
231 params: &'a ListTasksParams,
232 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
233 Box::pin(async move {
234 let mut conditions = Vec::new();
236 let mut bind_values: Vec<String> = Vec::new();
237
238 if let Some(ref ctx) = params.context_id {
239 conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
240 bind_values.push(ctx.clone());
241 }
242 if let Some(ref status) = params.status {
243 conditions.push(format!("state = ?{}", bind_values.len() + 1));
244 bind_values.push(status.to_string());
245 }
246 if let Some(ref after) = params.status_timestamp_after {
252 let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
253 return Ok(TaskListResponse::new(Vec::new()));
254 };
255 conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
256 bind_values.push(after_dt);
257 }
258 if let Some(ref token) = params.page_token {
263 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
264 return Ok(TaskListResponse::new(Vec::new()));
265 };
266 let p = bind_values.len();
267 conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
268 bind_values.push(cursor_ua.to_string());
269 bind_values.push(cursor_id.to_string());
270 }
271
272 let where_clause = if conditions.is_empty() {
273 String::new()
274 } else {
275 format!("WHERE {}", conditions.join(" AND "))
276 };
277
278 let page_size = match params.page_size {
279 Some(0) | None => 50_u32,
280 Some(n) => n.min(1000),
281 };
282
283 let limit = super::pagination::fetch_limit(page_size);
286 let limit_param = bind_values.len() + 1;
287 let sql = format!(
288 "SELECT updated_at, data FROM tasks {where_clause} \
289 ORDER BY updated_at DESC, id DESC LIMIT ?{limit_param}"
290 );
291
292 let mut query = sqlx::query_as::<_, (String, String)>(&sql);
293 for val in &bind_values {
294 query = query.bind(val);
295 }
296 query = query.bind(limit);
297
298 let rows: Vec<(String, String)> =
299 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
300
301 let mut rows: Vec<(String, Task)> = rows
302 .into_iter()
303 .map(|(updated_at, data)| {
304 serde_json::from_str::<Task>(&data)
305 .map(|task| (updated_at, task))
306 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
307 })
308 .collect::<A2aResult<Vec<_>>>()?;
309
310 let next_page_token =
311 if super::pagination::has_next_page(rows.len(), page_size as usize) {
312 rows.truncate(page_size as usize);
313 rows.last()
314 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
315 .unwrap_or_default()
316 } else {
317 String::new()
318 };
319
320 #[allow(clippy::cast_possible_truncation)]
321 let page_len = rows.len() as u32;
322 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
323 let mut response = TaskListResponse::new(tasks);
324 response.next_page_token = next_page_token;
325 response.page_size = page_len;
326 Ok(response)
327 })
328 }
329
330 fn insert_if_absent<'a>(
331 &'a self,
332 task: &'a Task,
333 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
334 Box::pin(async move {
335 let id = task.id.0.as_str();
336 let context_id = task.context_id.0.as_str();
337 let state = task.status.state.to_string();
338 let data = serde_json::to_string(task)
339 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
340
341 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
342 let result = sqlx::query(
343 "INSERT OR IGNORE INTO tasks (id, context_id, state, data, updated_at)
344 VALUES (?1, ?2, ?3, ?4, COALESCE(?5, strftime('%Y-%m-%d %H:%M:%f','now')))",
345 )
346 .bind(id)
347 .bind(context_id)
348 .bind(&state)
349 .bind(&data)
350 .bind(&status_ts)
351 .execute(&self.pool)
352 .await
353 .map_err(to_a2a_error)?;
354
355 Ok(result.rows_affected() > 0)
356 })
357 }
358
359 fn delete<'a>(
360 &'a self,
361 id: &'a TaskId,
362 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
363 Box::pin(async move {
364 sqlx::query("DELETE FROM tasks WHERE id = ?1")
365 .bind(id.0.as_str())
366 .execute(&self.pool)
367 .await
368 .map_err(to_a2a_error)?;
369 Ok(())
370 })
371 }
372
373 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
374 Box::pin(async move {
375 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
376 .fetch_one(&self.pool)
377 .await
378 .map_err(to_a2a_error)?;
379 #[allow(clippy::cast_sign_loss)]
380 Ok(row.0 as u64)
381 })
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
389
390 async fn make_store() -> SqliteTaskStore {
391 SqliteTaskStore::new("sqlite::memory:")
392 .await
393 .expect("failed to create in-memory store")
394 }
395
396 fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
397 Task {
398 id: TaskId::new(id),
399 context_id: ContextId::new(ctx),
400 status: TaskStatus::new(state),
401 history: None,
402 artifacts: None,
403 metadata: None,
404 }
405 }
406
407 #[tokio::test]
408 async fn save_and_get_round_trip() {
409 let store = make_store().await;
410 let task = make_task("t1", "ctx1", TaskState::Submitted);
411 store.save(&task).await.expect("save should succeed");
412
413 let retrieved = store
414 .get(&TaskId::new("t1"))
415 .await
416 .expect("get should succeed");
417 let retrieved = retrieved.expect("task should exist after save");
418 assert_eq!(retrieved.id, TaskId::new("t1"), "task id should match");
419 assert_eq!(
420 retrieved.context_id,
421 ContextId::new("ctx1"),
422 "context_id should match"
423 );
424 assert_eq!(
425 retrieved.status.state,
426 TaskState::Submitted,
427 "state should match"
428 );
429 }
430
431 #[tokio::test]
432 async fn get_returns_none_for_missing_task() {
433 let store = make_store().await;
434 let result = store
435 .get(&TaskId::new("nonexistent"))
436 .await
437 .expect("get should succeed");
438 assert!(
439 result.is_none(),
440 "get should return None for a missing task"
441 );
442 }
443
444 #[tokio::test]
445 async fn save_overwrites_existing_task() {
446 let store = make_store().await;
447 let task1 = make_task("t1", "ctx1", TaskState::Submitted);
448 store.save(&task1).await.expect("first save should succeed");
449
450 let task2 = make_task("t1", "ctx1", TaskState::Working);
451 store
452 .save(&task2)
453 .await
454 .expect("second save should succeed");
455
456 let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
457 assert_eq!(
458 retrieved.status.state,
459 TaskState::Working,
460 "state should be updated after overwrite"
461 );
462 }
463
464 #[tokio::test]
465 async fn insert_if_absent_returns_true_for_new_task() {
466 let store = make_store().await;
467 let task = make_task("t1", "ctx1", TaskState::Submitted);
468 let inserted = store
469 .insert_if_absent(&task)
470 .await
471 .expect("insert_if_absent should succeed");
472 assert!(
473 inserted,
474 "insert_if_absent should return true for a new task"
475 );
476 }
477
478 #[tokio::test]
479 async fn insert_if_absent_returns_false_for_existing_task() {
480 let store = make_store().await;
481 let task = make_task("t1", "ctx1", TaskState::Submitted);
482 store.save(&task).await.unwrap();
483
484 let duplicate = make_task("t1", "ctx1", TaskState::Working);
485 let inserted = store
486 .insert_if_absent(&duplicate)
487 .await
488 .expect("insert_if_absent should succeed");
489 assert!(
490 !inserted,
491 "insert_if_absent should return false for an existing task"
492 );
493
494 let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
496 assert_eq!(
497 retrieved.status.state,
498 TaskState::Submitted,
499 "original state should be preserved"
500 );
501 }
502
503 #[tokio::test]
504 async fn delete_removes_task() {
505 let store = make_store().await;
506 store
507 .save(&make_task("t1", "ctx1", TaskState::Submitted))
508 .await
509 .unwrap();
510
511 store
512 .delete(&TaskId::new("t1"))
513 .await
514 .expect("delete should succeed");
515
516 let result = store.get(&TaskId::new("t1")).await.unwrap();
517 assert!(result.is_none(), "task should be gone after delete");
518 }
519
520 #[tokio::test]
521 async fn delete_nonexistent_is_ok() {
522 let store = make_store().await;
523 let result = store.delete(&TaskId::new("nonexistent")).await;
524 assert!(
525 result.is_ok(),
526 "deleting a nonexistent task should not error"
527 );
528 }
529
530 #[tokio::test]
531 async fn count_tracks_inserts_and_deletes() {
532 let store = make_store().await;
533 assert_eq!(
534 store.count().await.unwrap(),
535 0,
536 "empty store should have count 0"
537 );
538
539 store
540 .save(&make_task("t1", "ctx1", TaskState::Submitted))
541 .await
542 .unwrap();
543 store
544 .save(&make_task("t2", "ctx1", TaskState::Working))
545 .await
546 .unwrap();
547 assert_eq!(
548 store.count().await.unwrap(),
549 2,
550 "count should be 2 after two saves"
551 );
552
553 store.delete(&TaskId::new("t1")).await.unwrap();
554 assert_eq!(
555 store.count().await.unwrap(),
556 1,
557 "count should be 1 after one delete"
558 );
559 }
560
561 #[tokio::test]
562 async fn list_all_tasks() {
563 let store = make_store().await;
564 store
565 .save(&make_task("t1", "ctx1", TaskState::Submitted))
566 .await
567 .unwrap();
568 store
569 .save(&make_task("t2", "ctx2", TaskState::Working))
570 .await
571 .unwrap();
572
573 let params = ListTasksParams::default();
574 let response = store.list(¶ms).await.expect("list should succeed");
575 assert_eq!(response.tasks.len(), 2, "list should return all tasks");
576 }
577
578 #[tokio::test]
579 async fn list_filter_by_context_id() {
580 let store = make_store().await;
581 store
582 .save(&make_task("t1", "ctx-a", TaskState::Submitted))
583 .await
584 .unwrap();
585 store
586 .save(&make_task("t2", "ctx-b", TaskState::Submitted))
587 .await
588 .unwrap();
589 store
590 .save(&make_task("t3", "ctx-a", TaskState::Working))
591 .await
592 .unwrap();
593
594 let params = ListTasksParams {
595 context_id: Some("ctx-a".to_string()),
596 ..Default::default()
597 };
598 let response = store.list(¶ms).await.unwrap();
599 assert_eq!(
600 response.tasks.len(),
601 2,
602 "should return only tasks with context_id ctx-a"
603 );
604 }
605
606 #[tokio::test]
607 async fn list_filter_by_status() {
608 let store = make_store().await;
609 store
610 .save(&make_task("t1", "ctx1", TaskState::Submitted))
611 .await
612 .unwrap();
613 store
614 .save(&make_task("t2", "ctx1", TaskState::Working))
615 .await
616 .unwrap();
617 store
618 .save(&make_task("t3", "ctx1", TaskState::Working))
619 .await
620 .unwrap();
621
622 let params = ListTasksParams {
623 status: Some(TaskState::Working),
624 ..Default::default()
625 };
626 let response = store.list(¶ms).await.unwrap();
627 assert_eq!(response.tasks.len(), 2, "should return only Working tasks");
628 }
629
630 #[tokio::test]
631 async fn list_pagination() {
632 let store = make_store().await;
633 for i in 0..5 {
635 store
636 .save(&make_task(
637 &format!("task-{i:03}"),
638 "ctx1",
639 TaskState::Submitted,
640 ))
641 .await
642 .unwrap();
643 }
644
645 let params = ListTasksParams {
647 page_size: Some(2),
648 ..Default::default()
649 };
650 let response = store.list(¶ms).await.unwrap();
651 assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
652 assert!(
653 !response.next_page_token.is_empty(),
654 "should have a next page token"
655 );
656
657 let params2 = ListTasksParams {
659 page_size: Some(2),
660 page_token: Some(response.next_page_token),
661 ..Default::default()
662 };
663 let response2 = store.list(¶ms2).await.unwrap();
664 assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
665 assert!(
666 !response2.next_page_token.is_empty(),
667 "should still have a next page token"
668 );
669
670 let params3 = ListTasksParams {
672 page_size: Some(2),
673 page_token: Some(response2.next_page_token),
674 ..Default::default()
675 };
676 let response3 = store.list(¶ms3).await.unwrap();
677 assert_eq!(response3.tasks.len(), 1, "last page should have 1 task");
678 assert!(
679 response3.next_page_token.is_empty(),
680 "last page should have no next page token"
681 );
682 }
683
684 #[tokio::test]
685 async fn list_orders_most_recently_updated_first() {
686 let store = make_store().await;
687 for id in ["c", "a", "b"] {
690 store
691 .save(&make_task(id, "ctx1", TaskState::Submitted))
692 .await
693 .unwrap();
694 tokio::time::sleep(std::time::Duration::from_millis(3)).await;
695 }
696
697 let response = store.list(&ListTasksParams::default()).await.unwrap();
698 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
699 assert_eq!(
700 ids,
701 vec!["b", "a", "c"],
702 "tasks should be ordered most-recently-updated first"
703 );
704 }
705
706 fn make_task_with_ts(id: &str, ctx: &str, state: TaskState, ts: &str) -> Task {
708 let mut task = make_task(id, ctx, state);
709 task.status.timestamp = Some(ts.to_owned());
710 task
711 }
712
713 #[tokio::test]
716 async fn list_orders_by_status_timestamp_not_write_order() {
717 let store = make_store().await;
718 for (id, ts) in [
720 ("middle", "2026-01-02T00:00:00.000Z"),
721 ("newest", "2026-01-03T00:00:00.000Z"),
722 ("oldest", "2026-01-01T00:00:00.000Z"),
723 ] {
724 store
725 .save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
726 .await
727 .unwrap();
728 }
729
730 let response = store.list(&ListTasksParams::default()).await.unwrap();
731 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
732 assert_eq!(
733 ids,
734 vec!["newest", "middle", "oldest"],
735 "list must sort by status timestamp descending"
736 );
737 }
738
739 #[tokio::test]
742 async fn list_resave_without_status_change_keeps_position() {
743 let store = make_store().await;
744 store
745 .save(&make_task_with_ts(
746 "older",
747 "ctx1",
748 TaskState::Working,
749 "2026-01-01T00:00:00.000Z",
750 ))
751 .await
752 .unwrap();
753 store
754 .save(&make_task_with_ts(
755 "newer",
756 "ctx1",
757 TaskState::Working,
758 "2026-01-02T00:00:00.000Z",
759 ))
760 .await
761 .unwrap();
762
763 store
765 .save(&make_task_with_ts(
766 "older",
767 "ctx1",
768 TaskState::Working,
769 "2026-01-01T00:00:00.000Z",
770 ))
771 .await
772 .unwrap();
773
774 let response = store.list(&ListTasksParams::default()).await.unwrap();
775 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
776 assert_eq!(
777 ids,
778 vec!["newer", "older"],
779 "a status-preserving re-save must not reorder the list"
780 );
781 }
782
783 #[tokio::test]
785 async fn list_filters_by_status_timestamp_after() {
786 let store = make_store().await;
787 for (id, ts) in [
788 ("old", "2026-01-01T00:00:00.000Z"),
789 ("boundary", "2026-01-02T00:00:00.000Z"),
790 ("new", "2026-01-03T00:00:00.000Z"),
791 ] {
792 store
793 .save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
794 .await
795 .unwrap();
796 }
797
798 let params = ListTasksParams {
799 status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
800 ..Default::default()
801 };
802 let response = store.list(¶ms).await.unwrap();
803 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
804 assert_eq!(
805 ids,
806 vec!["new"],
807 "filter must be strictly-after (boundary excluded)"
808 );
809 }
810
811 #[tokio::test]
812 async fn list_reorders_on_update() {
813 let store = make_store().await;
814 for id in ["t1", "t2", "t3"] {
815 store
816 .save(&make_task(id, "ctx1", TaskState::Submitted))
817 .await
818 .unwrap();
819 tokio::time::sleep(std::time::Duration::from_millis(3)).await;
820 }
821
822 store
824 .save(&make_task("t1", "ctx1", TaskState::Working))
825 .await
826 .unwrap();
827
828 let response = store.list(&ListTasksParams::default()).await.unwrap();
829 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
830 assert_eq!(
831 ids,
832 vec!["t1", "t3", "t2"],
833 "an updated task must move to the front of the update order"
834 );
835 }
836
837 #[tokio::test]
838 async fn list_pagination_visits_every_task_once() {
839 let store = make_store().await;
843 for i in 0..25 {
844 store
845 .save(&make_task(
846 &format!("t{i:03}"),
847 "ctx1",
848 TaskState::Submitted,
849 ))
850 .await
851 .unwrap();
852 }
853
854 let mut seen = std::collections::HashSet::new();
855 let mut token: Option<String> = None;
856 loop {
857 let params = ListTasksParams {
858 page_size: Some(4),
859 page_token: token.clone(),
860 ..Default::default()
861 };
862 let page = store.list(¶ms).await.unwrap();
863 for t in &page.tasks {
864 assert!(seen.insert(t.id.0.clone()), "task {} seen twice", t.id.0);
865 }
866 if page.next_page_token.is_empty() {
867 break;
868 }
869 token = Some(page.next_page_token);
870 }
871 assert_eq!(seen.len(), 25, "every task must be visited exactly once");
872 }
873
874 #[tokio::test]
875 async fn list_malformed_page_token_returns_empty() {
876 let store = make_store().await;
877 store
878 .save(&make_task("t1", "ctx1", TaskState::Submitted))
879 .await
880 .unwrap();
881
882 let params = ListTasksParams {
885 page_token: Some("forged-cursor-no-separator".to_string()),
886 ..Default::default()
887 };
888 let response = store.list(¶ms).await.unwrap();
889 assert!(
890 response.tasks.is_empty(),
891 "malformed page_token should yield empty results"
892 );
893 }
894
895 #[test]
897 fn to_a2a_error_formats_message() {
898 let sqlite_err = sqlx::Error::RowNotFound;
899 let a2a_err = to_a2a_error(sqlite_err);
900 let msg = format!("{a2a_err}");
901 assert!(
902 msg.contains("sqlite error"),
903 "error message should contain 'sqlite error': {msg}"
904 );
905 }
906
907 #[tokio::test]
909 async fn with_migrations_creates_store() {
910 let result = SqliteTaskStore::with_migrations("sqlite::memory:").await;
912 assert!(
913 result.is_ok(),
914 "with_migrations should succeed on a fresh database"
915 );
916 let store = result.unwrap();
917 let count = store.count().await.unwrap();
918 assert_eq!(count, 0, "freshly migrated store should be empty");
919 }
920
921 #[tokio::test]
922 async fn list_empty_store() {
923 let store = make_store().await;
924 let params = ListTasksParams::default();
925 let response = store.list(¶ms).await.unwrap();
926 assert!(
927 response.tasks.is_empty(),
928 "list on empty store should return no tasks"
929 );
930 assert!(
931 response.next_page_token.is_empty(),
932 "no pagination token for empty results"
933 );
934 }
935}