use super::*;
fn test_db() -> Database {
let db = Database::new(":memory:").expect("in-memory db");
db.conn()
.execute_batch(
"CREATE TABLE IF NOT EXISTS task_events ( \
id TEXT PRIMARY KEY, \
task_id TEXT NOT NULL, \
parent_task_id TEXT, \
assigned_to TEXT, \
event_type TEXT NOT NULL CHECK ( \
event_type IN ('pending','assigned','running','progress','completed','failed','cancelled','retry') \
), \
summary TEXT, \
detail_json TEXT, \
percentage REAL, \
retry_count INTEGER NOT NULL DEFAULT 0, \
created_at TEXT NOT NULL DEFAULT (datetime('now')) \
); \
CREATE INDEX IF NOT EXISTS idx_task_events_task_id ON task_events(task_id); \
CREATE INDEX IF NOT EXISTS idx_task_events_parent ON task_events(parent_task_id); \
CREATE INDEX IF NOT EXISTS idx_task_events_assigned_to ON task_events(assigned_to); \
CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at DESC); \
CREATE INDEX IF NOT EXISTS idx_task_events_type ON task_events(event_type);",
)
.expect("task_events DDL");
db
}
fn make_event(task_id: &str, event_type: TaskLifecycleState) -> TaskEventRow {
TaskEventRow {
id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.to_string(),
parent_task_id: None,
assigned_to: None,
event_type,
summary: None,
detail_json: None,
percentage: None,
retry_count: 0,
created_at: String::new(), }
}
#[test]
fn lifecycle_state_round_trips_through_serde() {
let states = [
TaskLifecycleState::Pending,
TaskLifecycleState::Assigned,
TaskLifecycleState::Running,
TaskLifecycleState::Progress,
TaskLifecycleState::Completed,
TaskLifecycleState::Failed,
TaskLifecycleState::Cancelled,
TaskLifecycleState::Retry,
];
for state in &states {
let json = serde_json::to_string(state).expect("serialize");
let back: TaskLifecycleState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(state, &back, "round-trip failed for {:?}", state);
}
}
#[test]
fn lifecycle_state_display_is_lowercase() {
assert_eq!(TaskLifecycleState::Pending.as_str(), "pending");
assert_eq!(TaskLifecycleState::Assigned.as_str(), "assigned");
assert_eq!(TaskLifecycleState::Running.as_str(), "running");
assert_eq!(TaskLifecycleState::Progress.as_str(), "progress");
assert_eq!(TaskLifecycleState::Completed.as_str(), "completed");
assert_eq!(TaskLifecycleState::Failed.as_str(), "failed");
assert_eq!(TaskLifecycleState::Cancelled.as_str(), "cancelled");
assert_eq!(TaskLifecycleState::Retry.as_str(), "retry");
assert_eq!(format!("{}", TaskLifecycleState::Running), "running");
}
#[test]
fn lifecycle_state_is_copy() {
let s = TaskLifecycleState::Running;
let s2 = s; assert_eq!(s, s2);
}
#[test]
fn insert_and_query_task_events() {
let db = test_db();
let event = TaskEventRow {
id: "evt-1".to_string(),
task_id: "task-abc".to_string(),
parent_task_id: None,
assigned_to: Some("agent-alpha".to_string()),
event_type: TaskLifecycleState::Pending,
summary: Some("Task created".to_string()),
detail_json: Some(r#"{"source":"api"}"#.to_string()),
percentage: None,
retry_count: 0,
created_at: String::new(),
};
insert_task_event(&db, &event).unwrap();
let events = task_events_for_task(&db, "task-abc").unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, "evt-1");
assert_eq!(events[0].task_id, "task-abc");
assert_eq!(events[0].assigned_to.as_deref(), Some("agent-alpha"));
assert_eq!(events[0].event_type, TaskLifecycleState::Pending);
assert_eq!(events[0].summary.as_deref(), Some("Task created"));
}
#[test]
fn query_task_events_by_assigned_to() {
let db = test_db();
let mut e1 = make_event("task-1", TaskLifecycleState::Running);
e1.id = "e1".to_string();
e1.assigned_to = Some("agent-beta".to_string());
let mut e2 = make_event("task-2", TaskLifecycleState::Completed);
e2.id = "e2".to_string();
e2.assigned_to = Some("agent-gamma".to_string());
let mut e3 = make_event("task-3", TaskLifecycleState::Pending);
e3.id = "e3".to_string();
e3.assigned_to = Some("agent-beta".to_string());
insert_task_event(&db, &e1).unwrap();
insert_task_event(&db, &e2).unwrap();
insert_task_event(&db, &e3).unwrap();
let beta_events = task_events_for_agent(&db, "agent-beta").unwrap();
assert_eq!(beta_events.len(), 2);
assert!(
beta_events
.iter()
.all(|e| e.assigned_to.as_deref() == Some("agent-beta"))
);
let gamma_events = task_events_for_agent(&db, "agent-gamma").unwrap();
assert_eq!(gamma_events.len(), 1);
}
#[test]
fn latest_event_returns_most_recent() {
let db = test_db();
let mut e1 = make_event("task-x", TaskLifecycleState::Pending);
e1.id = "ex-1".to_string();
e1.created_at = "2026-01-01 09:00:00".to_string();
let mut e2 = make_event("task-x", TaskLifecycleState::Running);
e2.id = "ex-2".to_string();
e2.created_at = "2026-01-01 09:00:01".to_string();
insert_task_event(&db, &e1).unwrap();
insert_task_event(&db, &e2).unwrap();
let latest = latest_task_event(&db, "task-x").unwrap();
assert!(latest.is_some());
let row = latest.unwrap();
assert_eq!(row.task_id, "task-x");
assert_eq!(
row.event_type,
TaskLifecycleState::Running,
"latest should be running (later timestamp)"
);
assert!(latest_task_event(&db, "nonexistent").unwrap().is_none());
}
#[test]
fn current_state_for_task_returns_terminal_state() {
let db = test_db();
let mut e1 = make_event("task-y", TaskLifecycleState::Running);
e1.id = "ey-1".to_string();
e1.created_at = "2026-01-01 10:00:00".to_string();
let mut e2 = make_event("task-y", TaskLifecycleState::Completed);
e2.id = "ey-2".to_string();
e2.created_at = "2026-01-01 10:00:01".to_string();
insert_task_event(&db, &e1).unwrap();
insert_task_event(&db, &e2).unwrap();
let state = current_task_state(&db, "task-y").unwrap();
let state = state.expect("state should exist");
assert!(
state.is_terminal(),
"expected terminal state, got {:?}",
state
);
assert!(current_task_state(&db, "nonexistent").unwrap().is_none());
}
#[test]
fn recent_task_events_respects_limit() {
let db = test_db();
for i in 0..10u32 {
let mut e = make_event(&format!("task-{}", i), TaskLifecycleState::Pending);
e.id = format!("e-limit-{}", i);
e.created_at = format!("2026-01-01 12:{:02}:00", i);
insert_task_event(&db, &e).unwrap();
}
let events = recent_task_events(&db, 5).unwrap();
assert_eq!(events.len(), 5);
let all = recent_task_events(&db, 100).unwrap();
assert_eq!(all.len(), 10);
}
#[test]
fn retry_count_tracks_retries() {
let db = test_db();
let mut e1 = make_event("task-r", TaskLifecycleState::Running);
e1.id = "er-1".to_string();
e1.retry_count = 0;
let mut e2 = make_event("task-r", TaskLifecycleState::Retry);
e2.id = "er-2".to_string();
e2.retry_count = 1;
let mut e3 = make_event("task-r", TaskLifecycleState::Retry);
e3.id = "er-3".to_string();
e3.retry_count = 2;
insert_task_event(&db, &e1).unwrap();
insert_task_event(&db, &e2).unwrap();
insert_task_event(&db, &e3).unwrap();
assert_eq!(retry_count_for_task(&db, "task-r").unwrap(), 2);
assert_eq!(retry_count_for_task(&db, "nonexistent").unwrap(), 0);
}
#[test]
fn active_task_summaries_returns_only_non_terminal() {
let db = test_db();
let mut e1 = make_event("task-active", TaskLifecycleState::Pending);
e1.id = "act-1".to_string();
e1.created_at = "2026-01-01 11:00:00".to_string();
let mut e2 = make_event("task-active", TaskLifecycleState::Running);
e2.id = "act-2".to_string();
e2.created_at = "2026-01-01 11:00:01".to_string();
let mut e3 = make_event("task-done", TaskLifecycleState::Running);
e3.id = "done-1".to_string();
e3.created_at = "2026-01-01 11:01:00".to_string();
let mut e4 = make_event("task-done", TaskLifecycleState::Completed);
e4.id = "done-2".to_string();
e4.created_at = "2026-01-01 11:01:01".to_string();
let mut e5 = make_event("task-failed", TaskLifecycleState::Failed);
e5.id = "fail-1".to_string();
e5.created_at = "2026-01-01 11:02:00".to_string();
let mut e6 = make_event("task-progress", TaskLifecycleState::Progress);
e6.id = "prog-1".to_string();
e6.created_at = "2026-01-01 11:03:00".to_string();
for e in [&e1, &e2, &e3, &e4, &e5, &e6] {
insert_task_event(&db, e).unwrap();
}
let active = active_task_summaries(&db).unwrap();
assert_eq!(
active.len(),
2,
"expected 2 active tasks, got {}",
active.len()
);
let task_ids: Vec<&str> = active.iter().map(|r| r.task_id.as_str()).collect();
assert!(
task_ids.contains(&"task-active"),
"task-active should be active"
);
assert!(
task_ids.contains(&"task-progress"),
"task-progress should be active"
);
let active_row = active.iter().find(|r| r.task_id == "task-active").unwrap();
assert_eq!(active_row.event_type, TaskLifecycleState::Running);
assert!(!task_ids.contains(&"task-done"));
assert!(!task_ids.contains(&"task-failed"));
}
#[test]
fn subtask_events_for_parent_returns_latest_per_subtask() {
let db = test_db();
let parent_id = "parent-task-1";
let other_parent = "parent-task-2";
let mut s1 = make_event("subtask-a", TaskLifecycleState::Pending);
s1.id = "sa-1".to_string();
s1.parent_task_id = Some(parent_id.to_string());
s1.created_at = "2026-01-01 14:00:00".to_string();
let mut s2 = make_event("subtask-a", TaskLifecycleState::Running);
s2.id = "sa-2".to_string();
s2.parent_task_id = Some(parent_id.to_string());
s2.created_at = "2026-01-01 14:00:01".to_string();
let mut s3 = make_event("subtask-b", TaskLifecycleState::Completed);
s3.id = "sb-1".to_string();
s3.parent_task_id = Some(parent_id.to_string());
s3.created_at = "2026-01-01 14:01:00".to_string();
let mut s4 = make_event("subtask-c", TaskLifecycleState::Running);
s4.id = "sc-1".to_string();
s4.parent_task_id = Some(other_parent.to_string());
s4.created_at = "2026-01-01 14:02:00".to_string();
for e in [&s1, &s2, &s3, &s4] {
insert_task_event(&db, e).unwrap();
}
let subtasks = subtask_events_for_parent(&db, parent_id).unwrap();
assert_eq!(
subtasks.len(),
2,
"expected 2 subtasks, got {}",
subtasks.len()
);
let ids: Vec<&str> = subtasks.iter().map(|r| r.task_id.as_str()).collect();
assert!(ids.contains(&"subtask-a"));
assert!(ids.contains(&"subtask-b"));
assert!(
!ids.contains(&"subtask-c"),
"subtask-c belongs to a different parent"
);
let a_row = subtasks.iter().find(|r| r.task_id == "subtask-a").unwrap();
assert_eq!(a_row.event_type, TaskLifecycleState::Running);
let b_row = subtasks.iter().find(|r| r.task_id == "subtask-b").unwrap();
assert_eq!(b_row.event_type, TaskLifecycleState::Completed);
}
fn test_db_with_tasks() -> Database {
let db = test_db();
db.conn()
.execute_batch(
"CREATE TABLE IF NOT EXISTS tasks ( \
id TEXT PRIMARY KEY, \
title TEXT NOT NULL, \
description TEXT, \
status TEXT NOT NULL DEFAULT 'pending', \
priority INTEGER NOT NULL DEFAULT 0, \
source TEXT, \
created_at TEXT NOT NULL DEFAULT (datetime('now')), \
updated_at TEXT NOT NULL DEFAULT (datetime('now')) \
);",
)
.expect("tasks DDL");
db
}
#[test]
fn insert_event_syncs_task_status_to_running() {
let db = test_db_with_tasks();
db.conn()
.execute(
"INSERT INTO tasks (id, title) VALUES ('task-1', 'Test task')",
[],
)
.unwrap();
let status: String = db
.conn()
.query_row("SELECT status FROM tasks WHERE id = 'task-1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(status, "pending");
let event = make_event("task-1", TaskLifecycleState::Running);
insert_task_event(&db, &event).unwrap();
let status: String = db
.conn()
.query_row("SELECT status FROM tasks WHERE id = 'task-1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(status, "in_progress");
}
#[test]
fn insert_event_syncs_task_status_to_completed() {
let db = test_db_with_tasks();
db.conn()
.execute(
"INSERT INTO tasks (id, title) VALUES ('task-2', 'Another task')",
[],
)
.unwrap();
let event = make_event("task-2", TaskLifecycleState::Completed);
insert_task_event(&db, &event).unwrap();
let status: String = db
.conn()
.query_row("SELECT status FROM tasks WHERE id = 'task-2'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(status, "completed");
}
#[test]
fn insert_progress_event_does_not_change_task_status() {
let db = test_db_with_tasks();
db.conn()
.execute(
"INSERT INTO tasks (id, title, status) VALUES ('task-3', 'Prog task', 'in_progress')",
[],
)
.unwrap();
let event = make_event("task-3", TaskLifecycleState::Progress);
insert_task_event(&db, &event).unwrap();
let status: String = db
.conn()
.query_row("SELECT status FROM tasks WHERE id = 'task-3'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
status, "in_progress",
"Progress events should not change task status"
);
}
#[test]
fn insert_event_for_synthetic_task_id_does_not_error() {
let db = test_db_with_tasks();
let event = make_event("turn-abc-sub-0", TaskLifecycleState::Completed);
insert_task_event(&db, &event).unwrap(); }