mod dependencies;
pub(crate) mod fragments;
mod hydration;
mod projects;
mod recent_actions;
mod search;
mod sidebar;
mod sorting;
mod sync_history;
mod tasks;
mod types;
#[allow(unused_imports)]
pub(crate) use dependencies::{TaskDependencyItem, TaskDependencySummary, task_dependency_summary};
#[allow(unused_imports)]
pub(crate) use hydration::build_task_list_items;
#[allow(unused_imports)]
pub(crate) use projects::{list_project_items, list_project_items_in_workspace};
#[allow(unused_imports)]
pub(crate) use recent_actions::list_recent_actions_in_workspace;
#[allow(unused_imports)]
pub(crate) use search::{
SearchMatchedField, TaskSearchPreviewResult, TaskSearchPreviewResultSet, TaskSearchQuery,
TaskSearchResult, TaskSearchResultSet, search_task_item_set_in_workspace, search_task_items,
search_task_items_in_workspace, search_task_preview_set_in_workspace,
};
#[allow(unused_imports)]
pub(crate) use sidebar::{
sidebar_counts, sidebar_counts_for_scope_in_workspace, sidebar_counts_in_workspace,
};
#[allow(unused_imports)]
pub(crate) use sync_history::{SyncHistoryStats, sync_history_stats};
#[allow(unused_imports)]
pub(crate) use tasks::{list_task_items, list_task_items_in_workspace};
#[allow(unused_imports)]
pub(crate) use types::{
ProjectListItem, RecentActionItem, RecentActionTarget, SidebarCounts, SortDirection,
TaskDependencyLink, TaskFilters, TaskListItem, TaskNote, TaskQueryMode, TaskSort,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::test_conn;
use sqlx::SqliteConnection;
async fn seed_default_project(conn: &mut SqliteConnection) {
sqlx::query(
"INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
VALUES ('PROJECT000000001', 'app', 'app', 'APP', 't', 't')",
)
.execute(&mut *conn)
.await
.unwrap();
}
async fn insert_test_task(
conn: &mut SqliteConnection,
id: &str,
title: &str,
status: &str,
priority: &str,
created_at: &str,
) {
sqlx::query(
"INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
VALUES (?, ?, '', 'PROJECT000000001', ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(title)
.bind(status)
.bind(priority)
.bind(created_at)
.bind(created_at)
.bind(created_at)
.execute(&mut *conn)
.await
.unwrap();
}
async fn insert_test_label(conn: &mut SqliteConnection, task_id: &str, label: &str) {
let workspace_id = crate::workspaces::active_workspace_id();
sqlx::query(
"INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, 't')",
)
.bind(&workspace_id)
.bind(label)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
.bind(&workspace_id)
.bind(task_id)
.bind(label)
.execute(&mut *conn)
.await
.unwrap();
}
async fn insert_test_conflict(conn: &mut SqliteConnection, task_id: &str, resolved: bool) {
let resolved = if resolved { 1_i64 } else { 0_i64 };
sqlx::query(
"INSERT INTO conflicts(workspace_id, task_id, field, base_version, local_value, remote_value,
local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
VALUES (?, ?, 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, ?)",
)
.bind(crate::workspaces::active_workspace_id())
.bind(task_id)
.bind(crate::ids::new_id())
.bind(crate::ids::now())
.bind(resolved)
.execute(&mut *conn)
.await
.unwrap();
}
fn listed_titles(items: &[TaskListItem]) -> Vec<&str> {
items.iter().map(|item| item.task.title.as_str()).collect()
}
fn listed_titles_from_search(items: &[TaskSearchResult]) -> Vec<&str> {
items
.iter()
.map(|item| item.item.task.title.as_str())
.collect()
}
struct ActiveWorkspaceGuard {
previous: crate::workspaces::Workspace,
}
impl ActiveWorkspaceGuard {
fn set(workspace: crate::workspaces::Workspace) -> Self {
let previous = crate::workspaces::active_workspace();
crate::workspaces::set_active_workspace(workspace);
Self { previous }
}
}
impl Drop for ActiveWorkspaceGuard {
fn drop(&mut self) {
crate::workspaces::set_active_workspace(self.previous.clone());
}
}
async fn seed_workspace_project(
conn: &mut SqliteConnection,
workspace_id: &str,
key: &str,
name: &str,
prefix: &str,
) {
sqlx::query(
"INSERT INTO projects(id, workspace_id, key, name, prefix, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 't', 't')",
)
.bind(crate::ids::new_id())
.bind(workspace_id)
.bind(key)
.bind(name)
.bind(prefix)
.execute(&mut *conn)
.await
.unwrap();
}
async fn seed_workspace_label(conn: &mut SqliteConnection, workspace_id: &str, name: &str) {
sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, 't')")
.bind(workspace_id)
.bind(name)
.execute(&mut *conn)
.await
.unwrap();
}
#[allow(clippy::too_many_arguments)]
async fn seed_workspace_task(
conn: &mut SqliteConnection,
workspace_id: &str,
id: &str,
title: &str,
project_key: &str,
status: &str,
priority: &str,
created_at: &str,
) {
sqlx::query(
"INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
VALUES (?, ?, ?, '', (SELECT id FROM projects WHERE workspace_id = ? AND key = ?), ?, ?, ?, ?, ?)",
)
.bind(workspace_id)
.bind(id)
.bind(title)
.bind(workspace_id)
.bind(project_key)
.bind(status)
.bind(priority)
.bind(created_at)
.bind(created_at)
.bind(created_at)
.execute(&mut *conn)
.await
.unwrap();
}
async fn seed_workspace_task_label(
conn: &mut SqliteConnection,
workspace_id: &str,
task_id: &str,
label: &str,
) {
sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
.bind(workspace_id)
.bind(task_id)
.bind(label)
.execute(&mut *conn)
.await
.unwrap();
}
async fn seed_workspace_conflict(
conn: &mut SqliteConnection,
workspace_id: &str,
task_id: &str,
) {
sqlx::query(
"INSERT INTO conflicts(workspace_id, task_id, field, base_version, local_value, remote_value,
local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
VALUES (?, ?, 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, 0)",
)
.bind(workspace_id)
.bind(task_id)
.bind(crate::ids::new_id())
.bind(crate::ids::now())
.execute(&mut *conn)
.await
.unwrap();
}
#[tokio::test]
async fn queue_sort_orders_status_then_priority_then_created_at() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title, status, priority, created_at) in [
("0000000000000001", "done urgent", "done", "urgent", "001"),
("0000000000000002", "inbox urgent", "inbox", "urgent", "002"),
("0000000000000003", "active low", "active", "low", "003"),
("0000000000000004", "todo urgent", "todo", "urgent", "004"),
(
"0000000000000005",
"active urgent",
"active",
"urgent",
"005",
),
] {
insert_test_task(&mut conn, id, title, status, priority, created_at).await;
}
let items = list_task_items(
&mut conn,
TaskFilters {
hide_done: true,
..TaskFilters::default()
},
TaskQueryMode::RankedQueue,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(
listed_titles(&items),
["active urgent", "todo urgent", "active low", "inbox urgent"]
);
}
#[tokio::test]
async fn queue_view_hides_done_and_canceled_tasks() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title, status) in [
("0000000000000111", "todo task", "todo"),
("0000000000000112", "done task", "done"),
("0000000000000113", "canceled task", "canceled"),
] {
insert_test_task(&mut conn, id, title, status, "none", "001").await;
}
let items = list_task_items(
&mut conn,
TaskFilters {
hide_done: true,
..TaskFilters::default()
},
TaskQueryMode::RankedQueue,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(listed_titles(&items), ["todo task"]);
let counts = sidebar_counts(&mut conn).await.unwrap();
assert_eq!(counts.open, 1);
assert_eq!(counts.done, 2);
}
#[tokio::test]
async fn priority_sort_orders_priority_then_created_at() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title, priority, created_at) in [
("0000000000000101", "none old", "none", "001"),
("0000000000000102", "urgent", "urgent", "002"),
("0000000000000103", "high", "high", "003"),
("0000000000000104", "none new", "none", "004"),
] {
insert_test_task(&mut conn, id, title, "todo", priority, created_at).await;
}
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Priority,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(
listed_titles(&items),
["urgent", "high", "none new", "none old"]
);
}
#[tokio::test]
async fn created_sort_respects_direction() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title, created_at) in [
("0000000000000201", "first", "001"),
("0000000000000202", "second", "002"),
] {
insert_test_task(&mut conn, id, title, "todo", "none", created_at).await;
}
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Desc,
)
.await
.unwrap();
assert_eq!(items[0].task.title, "second");
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(items[0].task.title, "first");
}
#[tokio::test]
async fn conflicts_only_filter_returns_unresolved_conflicts() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title) in [
("0000000000000011", "conflicted"),
("0000000000000012", "clean"),
] {
insert_test_task(&mut conn, id, title, "todo", "none", "001").await;
}
sqlx::query(
"INSERT INTO conflicts(task_id, field, base_version, local_value, remote_value,
local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
VALUES ('0000000000000011', 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, 0)",
)
.bind(crate::ids::new_id())
.bind(crate::ids::now())
.execute(&mut *conn)
.await
.unwrap();
let items = list_task_items(
&mut conn,
TaskFilters {
conflicts_only: true,
..TaskFilters::default()
},
TaskQueryMode::RankedQueue,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].task.title, "conflicted");
assert!(items[0].has_conflict);
}
#[tokio::test]
async fn list_items_include_labels_and_unresolved_conflict_flags() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"0000000000000301",
"labeled",
"todo",
"none",
"001",
)
.await;
insert_test_task(
&mut conn,
"0000000000000302",
"resolved",
"todo",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"0000000000000303",
"plain",
"todo",
"none",
"003",
)
.await;
insert_test_label(&mut conn, "0000000000000301", "zeta").await;
insert_test_label(&mut conn, "0000000000000301", "alpha").await;
insert_test_conflict(&mut conn, "0000000000000301", false).await;
insert_test_conflict(&mut conn, "0000000000000302", true).await;
sqlx::query(
"INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
VALUES (?, '0000000000000302', '0000000000000301', '003')",
)
.bind(crate::workspaces::active_workspace_id())
.execute(&mut *conn)
.await
.unwrap();
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(
items[0].labels,
vec!["alpha".to_string(), "zeta".to_string()]
);
assert!(items[0].has_conflict);
assert_eq!(items[0].blocks[0].display_ref, "APP-0000000000000302");
assert_eq!(items[0].blocks[0].title, "resolved");
assert_eq!(items[1].depends_on[0].display_ref, "APP-0000000000000301");
assert_eq!(items[1].depends_on[0].title, "labeled");
assert!(!items[1].has_conflict);
assert!(items[2].labels.is_empty());
}
#[tokio::test]
async fn list_items_include_description_and_note_metadata() {
let (_temp, mut conn) = test_conn().await;
let workspace_id = crate::workspaces::active_workspace_id();
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"0000000000000501",
"documented",
"todo",
"none",
"001",
)
.await;
sqlx::query("UPDATE tasks SET description = 'details' WHERE workspace_id = ? AND id = ?")
.bind(&workspace_id)
.bind("0000000000000501")
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
VALUES (?, 'note-0501-a', '0000000000000501', 'older', '001', 'change-0501-a'),
(?, 'note-0501-b', '0000000000000501', 'newer', '002', 'change-0501-b')",
)
.bind(&workspace_id)
.bind(&workspace_id)
.execute(&mut *conn)
.await
.unwrap();
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(items[0].task.description, "details");
assert_eq!(
items[0]
.notes
.iter()
.map(|note| note.body.as_str())
.collect::<Vec<_>>(),
["newer", "older"]
);
}
#[tokio::test]
async fn list_items_preserve_display_refs_with_hidden_collisions() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"ABCD000000000001",
"visible",
"todo",
"none",
"001",
)
.await;
insert_test_task(&mut conn, "ABCD999999999999", "done", "done", "none", "002").await;
let items = list_task_items(
&mut conn,
TaskFilters {
hide_done: true,
..TaskFilters::default()
},
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].display_ref, "APP-ABCD0");
}
#[tokio::test]
async fn search_filter_matches_titles_and_descriptions() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
for (id, title, description, created_at) in [
(
"0000000000000501",
"Title match needle",
"plain body",
"001",
),
(
"0000000000000502",
"Body only",
"body contains needle",
"002",
),
("0000000000000503", "Unrelated", "plain body", "003"),
] {
sqlx::query(
"INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
VALUES (?, ?, ?, 'PROJECT000000001', 'todo', 'none', ?, ?, ?)",
)
.bind(id)
.bind(title)
.bind(description)
.bind(created_at)
.bind(created_at)
.bind(created_at)
.execute(&mut *conn)
.await
.unwrap();
}
let items = list_task_items(
&mut conn,
TaskFilters {
search: Some("needle".to_string()),
..TaskFilters::default()
},
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(listed_titles(&items), ["Title match needle", "Body only"]);
}
#[tokio::test]
async fn task_search_finds_done_labels_and_notes() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Done release cleanup",
"done",
"high",
"001",
)
.await;
insert_test_task(
&mut conn,
"8KQ9A1X4MV2P8D6R",
"Plain inbox",
"inbox",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"9KQ9A1X4MV2P8D6R",
"Add iOS API auth flow",
"todo",
"medium",
"003",
)
.await;
insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "security").await;
sqlx::query(
"UPDATE tasks SET description = 'schedule pager rotation handoff' WHERE id = '9KQ9A1X4MV2P8D6R'",
)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO notes(id, task_id, body, created_at, change_id)
VALUES ('note-search', '7KQ9A1X4MV2P8D6R', 'contains pager rotation context', '003', 'change-search')",
)
.execute(&mut *conn)
.await
.unwrap();
let done = search_task_items(
&mut conn,
TaskSearchQuery {
text: "release cleanup".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(listed_titles_from_search(&done), ["Done release cleanup"]);
assert_eq!(done[0].matched_field, SearchMatchedField::Title);
let label = search_task_items(
&mut conn,
TaskSearchQuery {
text: "security".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(listed_titles_from_search(&label), ["Plain inbox"]);
assert_eq!(label[0].matched_field, SearchMatchedField::Label);
let title_tokens = search_task_items(
&mut conn,
TaskSearchQuery {
text: "ios auth".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&title_tokens),
["Add iOS API auth flow"]
);
assert_eq!(title_tokens[0].matched_field, SearchMatchedField::Title);
let note = search_task_items(
&mut conn,
TaskSearchQuery {
text: "pager rotation".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
let note_titles = listed_titles_from_search(¬e);
assert!(note_titles.contains(&"Done release cleanup"));
assert!(note_titles.contains(&"Add iOS API auth flow"));
let note_match = note
.iter()
.find(|r| r.item.task.title == "Done release cleanup")
.expect("note match should be in results");
assert_eq!(note_match.matched_field, SearchMatchedField::Note);
assert!(
note_match
.snippet
.as_deref()
.is_some_and(|value| value.contains("pager rotation"))
);
let quoted_note = search_task_items(
&mut conn,
TaskSearchQuery {
text: "\"pager rotation\"".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
let quoted_note_titles = listed_titles_from_search("ed_note);
assert!(quoted_note_titles.contains(&"Done release cleanup"));
assert!(quoted_note_titles.contains(&"Add iOS API auth flow"));
let quoted_note_match = quoted_note
.iter()
.find(|r| r.item.task.title == "Done release cleanup")
.expect("note match should be in results");
assert_eq!(quoted_note_match.matched_field, SearchMatchedField::Note);
assert!(
quoted_note_match
.snippet
.as_deref()
.is_some_and(|value| value.contains("pager rotation"))
);
let desc_phrase = search_task_items(
&mut conn,
TaskSearchQuery {
text: "\"pager rotation handoff\"".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&desc_phrase),
["Add iOS API auth flow"]
);
assert_eq!(
desc_phrase[0].matched_field,
SearchMatchedField::Description
);
assert!(
desc_phrase[0]
.snippet
.as_deref()
.is_some_and(|value| value.contains("pager rotation handoff"))
);
}
fn task_search_fts_match(workspace_id: &str, term: &str) -> String {
fn phrase(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
format!("workspace_token:{} {}", phrase(workspace_id), phrase(term))
}
async fn task_search_fts_match_count(conn: &mut SqliteConnection, expression: &str) -> i64 {
sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM task_search_fts WHERE task_search_fts MATCH ?",
)
.bind(expression)
.fetch_one(&mut *conn)
.await
.unwrap()
}
#[tokio::test]
async fn task_search_document_update_removes_stale_fts_terms() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"FTS maintenance probe",
"todo",
"none",
"001",
)
.await;
let workspace_id = crate::workspaces::active_workspace_id();
sqlx::query(
"UPDATE task_search_documents SET notes = 'obsolete pager phrase'
WHERE workspace_id = ? AND task_id = ?",
)
.bind(&workspace_id)
.bind("7KQ9A1X4MV2P8D6R")
.execute(&mut *conn)
.await
.unwrap();
let obsolete = task_search_fts_match(&workspace_id, "obsolete pager phrase");
assert_eq!(task_search_fts_match_count(&mut conn, &obsolete).await, 1);
sqlx::query(
"UPDATE task_search_documents SET notes = 'fresh pager phrase'
WHERE workspace_id = ? AND task_id = ?",
)
.bind(&workspace_id)
.bind("7KQ9A1X4MV2P8D6R")
.execute(&mut *conn)
.await
.unwrap();
assert_eq!(task_search_fts_match_count(&mut conn, &obsolete).await, 0);
let fresh = task_search_fts_match(&workspace_id, "fresh pager phrase");
assert_eq!(task_search_fts_match_count(&mut conn, &fresh).await, 1);
}
#[tokio::test]
async fn task_search_requires_contiguous_text_matches() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Fix dashboard timers resetting spuriously for idle agents",
"done",
"medium",
"001",
)
.await;
insert_test_task(
&mut conn,
"8KQ9A1X4MV2P8D6R",
"Automate multi-shell testing for shell-sensitive test cases",
"done",
"medium",
"002",
)
.await;
let items = search_task_items(
&mut conn,
TaskSearchQuery {
text: "testing".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&items),
["Automate multi-shell testing for shell-sensitive test cases"]
);
assert_eq!(items[0].matched_field, SearchMatchedField::Title);
}
#[tokio::test]
async fn task_search_ranks_refs_and_controls_deleted_results() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Needle in title",
"todo",
"none",
"001",
)
.await;
insert_test_task(
&mut conn,
"9KQ9A1X4MV2P8D6R",
"Deleted needle",
"todo",
"none",
"002",
)
.await;
sqlx::query("UPDATE tasks SET deleted = 1 WHERE id = '9KQ9A1X4MV2P8D6R'")
.execute(&mut *conn)
.await
.unwrap();
let by_ref = search_task_items(
&mut conn,
TaskSearchQuery {
text: "7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(by_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(by_ref[0].matched_field, SearchMatchedField::Ref);
let qualified_ref = search_task_items(
&mut conn,
TaskSearchQuery {
text: "/APP-7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(qualified_ref.len(), 1);
assert_eq!(qualified_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(qualified_ref[0].matched_field, SearchMatchedField::Ref);
let without_deleted = search_task_items(
&mut conn,
TaskSearchQuery {
text: "deleted needle".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert!(without_deleted.is_empty());
let with_deleted = search_task_items(
&mut conn,
TaskSearchQuery {
text: "deleted needle".to_string(),
include_deleted: true,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(listed_titles_from_search(&with_deleted), ["Deleted needle"]);
assert!(with_deleted[0].item.task.deleted);
let single_token_without_deleted = search_task_items(
&mut conn,
TaskSearchQuery {
text: "needle".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&single_token_without_deleted),
["Needle in title"]
);
assert!(
single_token_without_deleted
.iter()
.all(|result| !result.item.task.deleted)
);
let deleted_by_ref = search_task_items(
&mut conn,
TaskSearchQuery {
text: "9KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(deleted_by_ref.len(), 1);
assert_eq!(deleted_by_ref[0].item.task.id, "9KQ9A1X4MV2P8D6R");
assert_eq!(deleted_by_ref[0].matched_field, SearchMatchedField::Ref);
assert!(deleted_by_ref[0].item.task.deleted);
}
#[tokio::test]
async fn queue_sort_ranks_conflicted_tasks_ahead_of_clean_peers() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"0000000000000401",
"clean",
"todo",
"none",
"001",
)
.await;
insert_test_task(
&mut conn,
"0000000000000402",
"conflicted",
"todo",
"none",
"002",
)
.await;
insert_test_conflict(&mut conn, "0000000000000402", false).await;
let items = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::RankedQueue,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(listed_titles(&items), ["conflicted", "clean"]);
}
#[tokio::test]
async fn explicit_workspace_read_apis_scope_results() {
let (_temp, mut conn) = test_conn().await;
let alpha_id = crate::workspaces::DEFAULT_WORKSPACE_ID.to_string();
let beta = crate::workspaces::create_workspace(&mut conn, "Beta")
.await
.unwrap();
seed_workspace_project(&mut conn, &alpha_id, "app", "Alpha", "ALP").await;
seed_workspace_project(&mut conn, &beta.id, "app", "Beta", "BET").await;
seed_workspace_label(&mut conn, &alpha_id, "shared").await;
seed_workspace_label(&mut conn, &beta.id, "shared").await;
seed_workspace_task(
&mut conn,
&alpha_id,
"ALPHA0000000001",
"alpha task",
"app",
"todo",
"high",
"001",
)
.await;
seed_workspace_task(
&mut conn,
&beta.id,
"BETA00000000001",
"beta task",
"app",
"done",
"low",
"002",
)
.await;
seed_workspace_task_label(&mut conn, &alpha_id, "ALPHA0000000001", "shared").await;
seed_workspace_task_label(&mut conn, &beta.id, "BETA00000000001", "shared").await;
seed_workspace_conflict(&mut conn, &alpha_id, "ALPHA0000000001").await;
let _guard = ActiveWorkspaceGuard::set(beta.clone());
let alpha_tasks = list_task_items_in_workspace(
&mut conn,
&alpha_id,
TaskFilters {
project: Some("app".to_string()),
label: Some("shared".to_string()),
conflicts_only: true,
..TaskFilters::default()
},
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(listed_titles(&alpha_tasks), ["alpha task"]);
assert_eq!(alpha_tasks[0].labels, vec!["shared".to_string()]);
assert!(alpha_tasks[0].has_conflict);
let beta_tasks = list_task_items_in_workspace(
&mut conn,
&beta.id,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(listed_titles(&beta_tasks), ["beta task"]);
let alpha_projects = list_project_items_in_workspace(&mut conn, &alpha_id)
.await
.unwrap();
assert_eq!(alpha_projects.len(), 1);
assert_eq!(alpha_projects[0].key, "app");
assert_eq!(alpha_projects[0].open_count, 1);
let beta_projects = list_project_items_in_workspace(&mut conn, &beta.id)
.await
.unwrap();
assert_eq!(beta_projects.len(), 1);
assert_eq!(beta_projects[0].key, "app");
assert_eq!(beta_projects[0].open_count, 0);
let alpha_counts = sidebar_counts_in_workspace(&mut conn, &alpha_id)
.await
.unwrap();
assert_eq!(alpha_counts.open, 1);
assert_eq!(alpha_counts.todo, 1);
assert_eq!(alpha_counts.conflicts, 1);
assert_eq!(alpha_counts.done, 0);
let beta_counts = sidebar_counts_in_workspace(&mut conn, &beta.id)
.await
.unwrap();
assert_eq!(beta_counts.open, 0);
assert_eq!(beta_counts.done, 1);
assert_eq!(beta_counts.conflicts, 0);
}
#[tokio::test]
async fn project_filters_use_project_id_after_key_change() {
let (_temp, mut conn) = test_conn().await;
let outcome = crate::projects::create_project_in_workspace(
&mut conn,
crate::workspaces::DEFAULT_WORKSPACE_ID,
"App",
)
.await
.unwrap();
sqlx::query(
"INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
VALUES (?, 'ABCDEF0000000000', 'kept', '', ?, 'todo', 'none', 't', 't', 't')",
)
.bind(crate::workspaces::DEFAULT_WORKSPACE_ID)
.bind(&outcome.project.id)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query("UPDATE projects SET key = 'renamed-app' WHERE id = ?")
.bind(&outcome.project.id)
.execute(&mut *conn)
.await
.unwrap();
let items = list_task_items(
&mut conn,
TaskFilters {
project: Some("renamed-app".to_string()),
..TaskFilters::default()
},
TaskQueryMode::Flat,
TaskSort::Updated,
SortDirection::Desc,
)
.await
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].task.project_key, "renamed-app");
}
#[tokio::test]
async fn active_workspace_wrappers_delegate_to_active_workspace() {
let (_temp, mut conn) = test_conn().await;
let alpha_id = crate::workspaces::DEFAULT_WORKSPACE_ID.to_string();
let beta = crate::workspaces::create_workspace(&mut conn, "Beta")
.await
.unwrap();
seed_workspace_project(&mut conn, &alpha_id, "alpha", "Alpha", "ALP").await;
seed_workspace_project(&mut conn, &beta.id, "beta", "Beta", "BET").await;
seed_workspace_task(
&mut conn,
&alpha_id,
"ALPHA0000000001",
"alpha task",
"alpha",
"todo",
"none",
"001",
)
.await;
seed_workspace_task(
&mut conn,
&beta.id,
"BETA00000000001",
"beta task",
"beta",
"todo",
"none",
"002",
)
.await;
let _guard = ActiveWorkspaceGuard::set(beta.clone());
let wrapper_tasks = list_task_items(
&mut conn,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
let explicit_tasks = list_task_items_in_workspace(
&mut conn,
&beta.id,
TaskFilters::default(),
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(
listed_titles(&wrapper_tasks),
listed_titles(&explicit_tasks)
);
assert_eq!(listed_titles(&wrapper_tasks), ["beta task"]);
assert_eq!(list_project_items(&mut conn).await.unwrap()[0].key, "beta");
assert_eq!(sidebar_counts(&mut conn).await.unwrap().open, 1);
}
#[tokio::test]
async fn task_search_accepts_unsafe_query_parser_input() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Pager rotation cleanup",
"todo",
"none",
"001",
)
.await;
for input in [
"",
"\"",
"\"\"",
"(",
")",
"a*b",
"\"(",
"AND OR NOT",
":",
"/",
"-",
"a:b",
"\"unfinished",
"x OR y",
] {
search_task_items(
&mut conn,
TaskSearchQuery {
text: input.to_string(),
include_deleted: false,
limit: 5,
},
)
.await
.expect("search input must parse and search safely");
}
}
#[tokio::test]
async fn task_search_finds_rows_backfilled_into_fts_index() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Backfilled migration text",
"todo",
"none",
"001",
)
.await;
let items = search_task_items(
&mut conn,
TaskSearchQuery {
text: "backfilled".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&items),
["Backfilled migration text"]
);
}
#[tokio::test]
async fn task_search_uses_fts_candidates_for_common_text() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Needle visible in fts",
"todo",
"none",
"001",
)
.await;
let before = search_task_items(
&mut conn,
TaskSearchQuery {
text: "visible in".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&before),
["Needle visible in fts"]
);
sqlx::query(
"DELETE FROM task_search_fts WHERE rowid = (SELECT doc_id FROM task_search_documents WHERE task_id = '7KQ9A1X4MV2P8D6R')",
)
.execute(&mut *conn)
.await
.unwrap();
let after = search_task_items(
&mut conn,
TaskSearchQuery {
text: "visible in".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert!(after.is_empty());
}
#[tokio::test]
async fn task_search_fts_updates_when_search_text_changes() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Original title",
"todo",
"none",
"001",
)
.await;
sqlx::query(
"UPDATE tasks SET title = 'Retitled orchard', description = 'body lagoon' WHERE id = '7KQ9A1X4MV2P8D6R'",
)
.execute(&mut *conn)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "orchard".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "lagoon".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
insert_test_label(&mut conn, "7KQ9A1X4MV2P8D6R", "garden").await;
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "garden".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
sqlx::query(
"DELETE FROM task_labels WHERE task_id = '7KQ9A1X4MV2P8D6R' AND label = 'garden'",
)
.execute(&mut *conn)
.await
.unwrap();
assert!(
search_task_items(
&mut conn,
TaskSearchQuery {
text: "garden".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
.is_empty()
);
sqlx::query(
"INSERT INTO notes(id, task_id, body, created_at, change_id) VALUES ('note-fts', '7KQ9A1X4MV2P8D6R', 'harbor memo', '002', 'change-fts')",
)
.execute(&mut *conn)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "harbor".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
sqlx::query("UPDATE notes SET body = 'summit memo' WHERE id = 'note-fts'")
.execute(&mut *conn)
.await
.unwrap();
assert!(
search_task_items(
&mut conn,
TaskSearchQuery {
text: "harbor".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
.is_empty()
);
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "summit".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
sqlx::query("UPDATE projects SET name = 'Beacon Project' WHERE key = 'app'")
.execute(&mut *conn)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(
&search_task_items(
&mut conn,
TaskSearchQuery {
text: "beacon".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap()
),
["Retitled orchard"]
);
}
#[tokio::test]
async fn task_search_ref_lane_keeps_glyph_normalization_out_of_text_lanes() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"looking glass",
"todo",
"none",
"001",
)
.await;
let text = search_task_items(
&mut conn,
TaskSearchQuery {
text: "100king".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert!(text.is_empty());
}
#[tokio::test]
async fn task_search_ref_lane_handles_durable_ids_and_punctuation() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Needle in title",
"todo",
"none",
"001",
)
.await;
insert_test_task(
&mut conn,
"9KQ9A1X4MV2P8D6R",
"Deleted needle",
"todo",
"none",
"002",
)
.await;
sqlx::query("UPDATE tasks SET deleted = 1 WHERE id = '9KQ9A1X4MV2P8D6R'")
.execute(&mut *conn)
.await
.unwrap();
let durable = search_task_items(
&mut conn,
TaskSearchQuery {
text: "7KQ9A1X4MV2P8D6R".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(durable[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(durable[0].matched_field, SearchMatchedField::Ref);
let punctuated = search_task_items(
&mut conn,
TaskSearchQuery {
text: "/APP.7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(punctuated[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(punctuated[0].matched_field, SearchMatchedField::Ref);
let spaced = search_task_items(
&mut conn,
TaskSearchQuery {
text: "APP 7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(spaced[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(spaced[0].matched_field, SearchMatchedField::Ref);
let wrong_prefix = search_task_items(
&mut conn,
TaskSearchQuery {
text: "/WRONG-7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert!(wrong_prefix.is_empty());
let short_deleted = search_task_items(
&mut conn,
TaskSearchQuery {
text: "9KQ".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert!(short_deleted.is_empty());
let query_also_in_title = search_task_items(
&mut conn,
TaskSearchQuery {
text: "needle".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
query_also_in_title[0].matched_field,
SearchMatchedField::Title
);
}
#[tokio::test]
async fn task_search_reranks_title_ref_label_note_and_metadata_evidence() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Orchard release",
"todo",
"medium",
"001",
)
.await;
insert_test_task(
&mut conn,
"8KQ9A1X4MV2P8D6R",
"Label and note",
"todo",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"9KQ9A1X4MV2P8D6R",
"Project metadata",
"todo",
"urgent",
"003",
)
.await;
insert_test_task(
&mut conn,
"AKQ9A1X4MV2P8D6R",
"Note only",
"todo",
"none",
"004",
)
.await;
insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "orchard").await;
sqlx::query(
"INSERT INTO notes(id, task_id, body, created_at, change_id)
VALUES ('note-rerank-a', '8KQ9A1X4MV2P8D6R', 'orchard detail', '002', 'change-rerank-a'),
('note-rerank-b', 'AKQ9A1X4MV2P8D6R', 'orchard detail', '004', 'change-rerank-b')",
)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
VALUES ('PROJECT000000002', 'meta', 'Orchard Metadata', 'MET', 't', 't')",
)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"UPDATE tasks SET project_id = 'PROJECT000000002' WHERE id = '9KQ9A1X4MV2P8D6R'",
)
.execute(&mut *conn)
.await
.unwrap();
let items = search_task_items(
&mut conn,
TaskSearchQuery {
text: "orchard".to_string(),
include_deleted: true,
limit: 10,
},
)
.await
.unwrap();
let position = |title: &str| {
items
.iter()
.position(|item| item.item.task.title == title)
.unwrap()
};
let title = position("Orchard release");
let label = position("Label and note");
let project = position("Project metadata");
let note = position("Note only");
assert!(title < label);
assert!(label < project);
assert!(project < note);
assert_eq!(items[title].matched_field, SearchMatchedField::Title);
assert_eq!(items[label].matched_field, SearchMatchedField::Label);
assert_eq!(items[project].matched_field, SearchMatchedField::Project);
assert_eq!(items[note].matched_field, SearchMatchedField::Note);
let by_ref = search_task_items(
&mut conn,
TaskSearchQuery {
text: "7KQ9".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(by_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
assert_eq!(by_ref[0].matched_field, SearchMatchedField::Ref);
}
#[tokio::test]
async fn task_search_reranks_multi_token_proximity_and_field_count() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(
&mut conn,
"7KQ9A1X4MV2P8D6R",
"Auth flow",
"todo",
"none",
"001",
)
.await;
insert_test_task(
&mut conn,
"8KQ9A1X4MV2P8D6R",
"Metadata bridge",
"todo",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"9KQ9A1X4MV2P8D6R",
"Note spread",
"todo",
"urgent",
"003",
)
.await;
insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "auth").await;
insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "flow").await;
sqlx::query(
"INSERT INTO notes(id, task_id, body, created_at, change_id)
VALUES ('note-token-a', '9KQ9A1X4MV2P8D6R', 'auth text with several unrelated words before flow', '003', 'change-token-a')",
)
.execute(&mut *conn)
.await
.unwrap();
let items = search_task_items(
&mut conn,
TaskSearchQuery {
text: "auth flow".to_string(),
include_deleted: false,
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
listed_titles_from_search(&items),
["Auth flow", "Metadata bridge", "Note spread"]
);
assert_eq!(items[0].matched_field, SearchMatchedField::Title);
assert_eq!(items[1].matched_field, SearchMatchedField::Label);
assert_eq!(items[2].matched_field, SearchMatchedField::Note);
}
#[tokio::test]
async fn epics_filter_lists_explicit_epics_with_children() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
let workspace_id = crate::workspaces::active_workspace_id();
insert_test_task(
&mut conn,
"EPIC00000000001",
"epic parent",
"todo",
"high",
"001",
)
.await;
insert_test_task(
&mut conn,
"EPIC00000000002",
"open child",
"active",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"EPIC00000000003",
"done parent",
"done",
"none",
"003",
)
.await;
insert_test_task(
&mut conn,
"EPIC00000000004",
"parent with done child",
"todo",
"none",
"004",
)
.await;
insert_test_task(
&mut conn,
"EPIC00000000005",
"done child",
"done",
"none",
"005",
)
.await;
insert_test_task(
&mut conn,
"EPIC00000000006",
"lonely task",
"todo",
"none",
"006",
)
.await;
sqlx::query(
"UPDATE tasks SET is_epic = 1 WHERE id IN ('EPIC00000000001', 'EPIC00000000004')",
)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO task_epic_links(workspace_id, child_task_id, epic_task_id, created_at)
VALUES (?, 'EPIC00000000002', 'EPIC00000000001', '002')",
)
.bind(&workspace_id)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
VALUES (?, 'EPIC00000000001', 'EPIC00000000003', '003')",
)
.bind(&workspace_id)
.execute(&mut *conn)
.await
.unwrap();
sqlx::query(
"INSERT INTO task_epic_links(workspace_id, child_task_id, epic_task_id, created_at)
VALUES (?, 'EPIC00000000005', 'EPIC00000000004', '005')",
)
.bind(&workspace_id)
.execute(&mut *conn)
.await
.unwrap();
let items = list_task_items(
&mut conn,
TaskFilters {
epics_only: true,
hide_done: true,
..TaskFilters::default()
},
TaskQueryMode::Flat,
TaskSort::Created,
SortDirection::Asc,
)
.await
.unwrap();
assert_eq!(
listed_titles(&items),
["epic parent", "parent with done child"]
);
assert_eq!(items[0].epic_children.len(), 1);
assert_eq!(items[0].epic_children[0].task_id, "EPIC00000000002");
assert_eq!(items[0].epic_children[0].display_ref, "APP-EPIC00000000002");
assert!(items[0].epic_children[0].unresolved);
}
#[tokio::test]
async fn sidebar_counts_include_epics_count() {
let (_temp, mut conn) = test_conn().await;
seed_default_project(&mut conn).await;
insert_test_task(&mut conn, "EPCS00000000001", "epic", "todo", "none", "001").await;
insert_test_task(
&mut conn,
"EPCS00000000002",
"child",
"active",
"none",
"002",
)
.await;
insert_test_task(
&mut conn,
"EPCS00000000003",
"not epic",
"todo",
"none",
"003",
)
.await;
sqlx::query("UPDATE tasks SET is_epic = 1 WHERE id = 'EPCS00000000001'")
.execute(&mut *conn)
.await
.unwrap();
let counts = sidebar_counts(&mut conn).await.unwrap();
assert_eq!(counts.open, 3);
assert_eq!(counts.epics, 1);
}
}