use super::*;
use crate::Role;
use crate::Tool;
use crate::Workspace;
use crate::role::DIAGNOSTICS_ROLE;
use crate::util::test::TicketBuilder;
use crate::util::test::assert_superseded_ticket;
use crate::util::test::expect_ticket;
use crate::util::test::init_test_stores;
use crate::util::test::make_ticket;
use crate::workspace::test_ws;
use crate::workspace::test_ws_named;
use strum::IntoEnumIterator;
use tempfile::TempDir;
enum InvalidInputScenario {
NonExistent,
CrossWorkspace,
SelfReference,
}
struct Case {
name: &'static str,
scenario: InvalidInputScenario,
}
async fn setup() -> (BoardStore, TempDir, String) {
let (store, tmp) = open_test_store().await;
let id = make_ticket(
&store,
&test_ws_named("/ws", "ws"),
"Test",
TicketPhase::Backlog,
)
.await;
(store, tmp, id)
}
#[tokio::test]
async fn test_get_ticket_phase() {
let (store, _tmp) = open_test_store().await;
assert!(
store
.get_ticket_phase("nonexistent")
.await
.expect("query")
.is_none()
);
let id = make_ticket(
&store,
&crate::workspace::test_ws_named("/workspace", "workspace"),
"Status Test",
TicketPhase::Planning,
)
.await;
let phase = crate::util::test::expect_ticket_phase(&store, &id).await;
assert_eq!(phase, TicketPhase::Planning);
store
.transition_to(&id, None, TicketPhase::ReadyForDevelopment, None)
.await
.expect("set");
let phase = crate::util::test::expect_ticket_phase(&store, &id).await;
assert_eq!(phase, TicketPhase::ReadyForDevelopment);
}
#[tokio::test]
async fn test_get_tickets_by_ids() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws_named("/ws", "test_ws");
let tickets = store
.get_tickets_by_ids(&[], crate::board::LoadComments::No)
.await
.expect("empty ids");
assert!(tickets.is_empty(), "empty ids should return empty vec");
let id_a = make_ticket(&store, &ws, "Ticket A", TicketPhase::Done).await;
let id_c = make_ticket(&store, &ws, "Ticket C", TicketPhase::Backlog).await;
let ids = vec![id_a.clone(), id_c.clone()];
let tickets = store
.get_tickets_by_ids(&ids, crate::board::LoadComments::No)
.await
.expect("get by ids");
assert_eq!(tickets.len(), 2, "should return exactly 2 tickets");
for t in &tickets {
match t.id.as_str() {
id if id == id_a => assert_eq!(t.title, "Ticket A"),
id if id == id_c => assert_eq!(t.title, "Ticket C"),
other => panic!("unexpected ticket id: {other}"),
}
}
}
#[test]
fn test_ticket_phase_parse_and_roundtrip() {
for v in TicketPhase::iter() {
let parsed: TicketPhase = v.as_ref().parse().unwrap();
assert_eq!(&parsed, &v, "roundtrip failed for {v}");
}
let err = "unknown_phase".parse::<TicketPhase>().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Invalid phase"),
"error should mention 'Invalid phase', got: {msg}"
);
assert!(
msg.contains("unknown_phase"),
"error should contain the invalid input value, got: {msg}"
);
assert!(
TicketPhase::iter().any(|p| msg.contains(p.as_ref())),
"error should list at least one valid phase, got: {msg}"
);
}
#[test]
fn test_display_name_no_underscores() {
for variant in TicketPhase::iter() {
let name = variant.display_name();
assert!(!name.is_empty(), "empty display_name for {variant}");
assert!(
!name.contains('_'),
"display_name for {variant} still has underscore: {name}"
);
}
}
#[tokio::test]
async fn test_unconditional_transition_clears_assignment() {
let (store, _tmp, id) = setup().await;
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"ws",
PipelineCheck::Skip,
)
.await
.expect("claim")
.expect("ticket exists");
store
.set_assigned_to(&claimed.id, Some(Role::Engineer.as_str()))
.await
.expect("set_assigned_to");
let ticket = store
.get_ticket(&id)
.await
.expect("get")
.expect("should exist");
assert!(
ticket.assigned_to.is_some(),
"assigned_to should be set after set_assigned_to"
);
store
.transition_to(&id, None, TicketPhase::DiagnosticsDone, None)
.await
.expect("update");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.phase, TicketPhase::DiagnosticsDone);
assert!(
ticket.assigned_to.is_none(),
"assigned_to should be cleared after unconditional transition"
);
}
#[tokio::test]
async fn test_guarded_transition() {
let (store, _tmp, id) = setup().await;
let result = store
.transition_to(
&id,
Some(TicketPhase::Done),
TicketPhase::InDevelopment,
None,
)
.await;
assert!(
result.is_err(),
"guarded transition with wrong phase should fail"
);
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.phase, TicketPhase::Backlog);
store
.transition_to(
&id,
Some(TicketPhase::Backlog),
TicketPhase::InDevelopment,
None,
)
.await
.expect("guarded transition with correct phase should succeed");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.phase, TicketPhase::InDevelopment);
}
#[tokio::test]
async fn test_add_comment() {
let (store, _tmp, id) = setup().await;
store
.add_comment(&id, Role::Engineer.as_str(), "done!")
.await
.expect("add comment");
let comments = store.get_comments(&id).await.expect("get comments");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].role, Role::Engineer.as_str());
assert_eq!(comments[0].content, "done!");
assert!(!comments[0].created_at.is_empty());
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert!(ticket.updated_at > ticket.created_at);
}
#[tokio::test]
async fn test_list_tickets() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
make_ticket(&store, &ws, "A", TicketPhase::Backlog).await;
make_ticket(&store, &ws, "B", TicketPhase::Backlog).await;
make_ticket(&store, &ws, "C", TicketPhase::Backlog).await;
let tickets = store
.list_all_tickets(Some("ws"), None)
.await
.expect("list");
assert_eq!(tickets.len(), 3);
let tickets = store
.list_all_tickets(Some("ws"), Some(TicketPhase::Done))
.await
.expect("list");
assert_eq!(tickets.len(), 0);
}
#[tokio::test]
async fn test_reset_inflight_tickets() {
struct Case {
name: &'static str,
suffix: &'static str,
start: TicketPhase,
expected: TicketPhase,
reservation: bool,
}
let cases = [
Case {
name: "Backlog unaffected (not an inflight phase)",
suffix: "a",
start: TicketPhase::Backlog,
expected: TicketPhase::Backlog,
reservation: false,
},
Case {
name: "Analysis → Backlog (no reservation)",
suffix: "b",
start: TicketPhase::Analysis,
expected: TicketPhase::Backlog,
reservation: false,
},
Case {
name: "InDevelopment → ReadyForDevelopment (reservation=1)",
suffix: "c",
start: TicketPhase::InDevelopment,
expected: TicketPhase::ReadyForDevelopment,
reservation: true,
},
Case {
name: "InDiagnostics → ReadyForDevelopment (reservation=1)",
suffix: "d",
start: TicketPhase::InDiagnostics,
expected: TicketPhase::ReadyForDevelopment,
reservation: true,
},
Case {
name: "InSanitation → QaPassed (reservation=1)",
suffix: "e",
start: TicketPhase::InSanitation,
expected: TicketPhase::QaPassed,
reservation: true,
},
Case {
name: "InQa → Reviewed (no reservation)",
suffix: "f",
start: TicketPhase::InQa,
expected: TicketPhase::Reviewed,
reservation: false,
},
Case {
name: "InReview → DiagnosticsDone (no reservation)",
suffix: "g",
start: TicketPhase::InReview,
expected: TicketPhase::DiagnosticsDone,
reservation: false,
},
];
let (store, _tmp) = open_test_store().await;
for case in &cases {
let ws = test_ws_named(&format!("/{}", case.suffix), case.suffix);
let id = make_ticket(&store, &ws, case.name, case.start).await;
store.reset_inflight_tickets().await.expect("reset");
let t = expect_ticket(&store, &id).await;
assert_eq!(
t.phase, case.expected,
"Case '{}': unexpected phase after reset",
case.name,
);
assert_eq!(
t.pipeline_reservation, case.reservation,
"Case '{}': unexpected pipeline_reservation after reset",
case.name,
);
assert!(
t.assigned_to.is_none(),
"Case '{}': assigned_to should be NULL after reset",
case.name,
);
}
}
#[tokio::test]
async fn test_claim_prefers_reserved_ticket() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let fresh_id = make_ticket(&store, &ws, "Fresh", TicketPhase::ReadyForDevelopment).await;
let reserved_id = make_ticket(&store, &ws, "Reserved", TicketPhase::ReadyForDevelopment).await;
store
.transition_to(
&reserved_id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
Some(true),
)
.await
.expect("set reservation");
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::ReadyForDevelopment,
TicketPhase::InDevelopment,
"ws",
PipelineCheck::Enforce,
)
.await
.expect("claim")
.expect("should claim a ticket");
assert_eq!(
claimed.id, reserved_id,
"Reserved ticket should be claimed before fresh one"
);
assert!(
!claimed.pipeline_reservation,
"Claim should clear pipeline_reservation"
);
let reserved_db = expect_ticket(&store, &reserved_id).await;
assert!(
!reserved_db.pipeline_reservation,
"Reservation should be 0 in DB after claim"
);
let fresh = expect_ticket(&store, &fresh_id).await;
assert_eq!(
fresh.phase,
TicketPhase::ReadyForDevelopment,
"Fresh ticket should still be at ReadyForDevelopment"
);
assert!(
!fresh.pipeline_reservation,
"Fresh ticket should have no reservation"
);
}
#[tokio::test]
async fn test_has_pipeline_blocker_reserved() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let id = make_ticket(&store, &ws, "Fresh", TicketPhase::ReadyForDevelopment).await;
assert!(
!store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Fresh ReadyForDevelopment ticket should not be a pipeline blocker"
);
store
.transition_to(
&id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
Some(true),
)
.await
.expect("set reservation");
assert!(
store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Reserved ReadyForDevelopment ticket should be a pipeline blocker"
);
store
.transition_to(
&id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
Some(false),
)
.await
.expect("clear reservation");
assert!(
!store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Non-reserved ReadyForDevelopment ticket should not be a pipeline blocker again"
);
}
async fn assert_active_excluding(
store: &BoardStore,
ws_name: &str,
exclude_id: &str,
expected: bool,
msg: impl std::fmt::Display,
) {
assert_eq!(
store
.has_active_tickets_excluding(ws_name, exclude_id)
.await
.expect("check"),
expected,
"{msg}"
);
}
async fn create_non_active_tickets(store: &BoardStore) -> Vec<String> {
let ws = test_ws_named("/ws_non", "ws_non");
vec![
make_ticket(store, &ws, "Done", TicketPhase::Done).await,
make_ticket(store, &ws, "Cancelled", TicketPhase::Cancelled).await,
make_ticket(store, &ws, "Failed", TicketPhase::Failed).await,
make_ticket(store, &ws, "Planning", TicketPhase::Planning).await,
make_ticket(store, &ws, "Backlog", TicketPhase::Backlog).await,
]
}
#[tokio::test]
async fn test_has_active_tickets_excluding() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let rfd_id = make_ticket(&store, &ws, "RFD", TicketPhase::ReadyForDevelopment).await;
let in_dev_id = make_ticket(&store, &ws, "InDev", TicketPhase::InDevelopment).await;
let done_id = make_ticket(&store, &ws, "Done", TicketPhase::Done).await;
let cancelled_id = make_ticket(&store, &ws, "Cancelled", TicketPhase::Cancelled).await;
assert_active_excluding(
&store,
"ws",
&done_id,
true,
"Should find active tickets (RFD + InDev) when excluding Done",
)
.await;
assert_active_excluding(
&store,
"ws",
&rfd_id,
true,
"Should find InDev as active when excluding RFD",
)
.await;
assert_active_excluding(
&store,
"ws",
&in_dev_id,
true,
"Should find RFD as active when excluding InDev",
)
.await;
for exclude in [&done_id, &cancelled_id] {
assert_active_excluding(
&store,
"ws",
exclude,
true,
"Non-active exclusion should still find active tickets",
)
.await;
}
assert_active_excluding(
&store,
"ws",
"nonexistent",
true,
"Should find active tickets for nonexistent exclude ID",
)
.await;
assert_active_excluding(
&store,
"other_ws",
&rfd_id,
false,
"Should not find active tickets in unrelated workspace",
)
.await;
let non_active_ids = create_non_active_tickets(&store).await;
for exclude in &non_active_ids {
assert_active_excluding(
&store,
"ws_non",
exclude,
false,
format!("Workspace with only non-active tickets should have no active tickets (excluded {exclude})"),
)
.await;
}
assert_active_excluding(
&store,
"ws_non",
"nonexistent",
false,
"No active tickets for nonexistent exclude ID in non-active-only workspace",
)
.await;
}
#[test]
fn test_pipeline_blockers_coverage() {
for phase in TRANSITORY_HANDOFF_PHASES {
assert!(
PIPELINE_BLOCKING_PHASES.contains(phase),
"\
TRANSITORY_HANDOFF_PHASES contains `{phase}` which is not in \
PIPELINE_BLOCKING_PHASES. Every transitory handoff phase must also \
be a pipeline blocker.\
",
);
}
let reset_from: Vec<TicketPhase> = BoardStore::RESET_TRANSITIONS
.iter()
.map(|t| t.from)
.collect();
for phase in PIPELINE_BLOCKING_PHASES {
let has_reset = reset_from.contains(phase);
assert!(
has_reset || phase.is_transitory_handoff(),
"\
PIPELINE_BLOCKING_PHASES contains `{phase}` which has no corresponding \
entry in RESET_TRANSITIONS and is not a transitory handoff phase \
(see `TicketPhase::is_transitory_handoff`). Either add a reset transition to \
RESET_TRANSITIONS, or mark the phase as transitory handoff in that method \
with a comment explaining why no agent is mid-execution in that state.\
",
);
}
}
#[tokio::test]
async fn test_claim_ticket_in_workspace() {
let (store, _tmp) = open_test_store().await;
let ws_a = test_ws_named("/ws_a", "workspace_a");
let ws_b = test_ws_named("/ws_b", "workspace_b");
let id_a = make_ticket(&store, &ws_a, "Ticket A", TicketPhase::Backlog).await;
let id_b = make_ticket(&store, &ws_b, "Ticket B", TicketPhase::Backlog).await;
let claimed_a = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_a",
PipelineCheck::Skip,
)
.await
.expect("claim in ws_a")
.expect("should claim ticket from ws_a");
assert_eq!(claimed_a.id, id_a);
assert_eq!(claimed_a.workspace_name, "workspace_a");
assert_eq!(claimed_a.phase, TicketPhase::InDevelopment);
assert!(claimed_a.assigned_to.is_none());
assert!(
store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_a",
PipelineCheck::Skip,
)
.await
.expect("second claim in ws_a")
.is_none(),
"no more tickets to claim in ws_a"
);
let claimed_b = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_b",
PipelineCheck::Skip,
)
.await
.expect("claim in ws_b")
.expect("should claim ticket from ws_b");
assert_eq!(claimed_b.id, id_b);
assert_eq!(claimed_b.workspace_name, "workspace_b");
}
#[allow(clippy::too_many_lines)]
#[tokio::test]
async fn test_claim_ticket_in_workspace_if_pipeline_free() {
enum Scenario {
SameWorkspace(TicketPhase),
DifferentWorkspace(TicketPhase),
NoBlocker,
}
struct Case {
name: &'static str,
suffix: &'static str,
scenario: Scenario,
}
let cases = [
Case {
name: "blocked by same-workspace pipeline ticket",
suffix: "blocked",
scenario: Scenario::SameWorkspace(TicketPhase::InReview),
},
Case {
name: "not blocked by cross-workspace pipeline ticket",
suffix: "cross",
scenario: Scenario::DifferentWorkspace(TicketPhase::InDevelopment),
},
Case {
name: "no blocker succeeds",
suffix: "none",
scenario: Scenario::NoBlocker,
},
];
let (store, _tmp) = open_test_store().await;
for case in &cases {
let suffix = case.suffix;
let (claim_ws_name, blocker_ws_name) = match &case.scenario {
Scenario::DifferentWorkspace(_) => (
format!("ws_{suffix}_claimable"),
format!("ws_{suffix}_blocker"),
),
Scenario::SameWorkspace(_) | Scenario::NoBlocker => {
let name = format!("ws_{suffix}");
(name.clone(), name)
}
};
let expected_claim = !matches!(case.scenario, Scenario::SameWorkspace(_));
let blocker_ws = test_ws_named(&format!("/{blocker_ws_name}"), &blocker_ws_name);
let claimable_ws = test_ws_named(&format!("/{claim_ws_name}"), &claim_ws_name);
if let Scenario::SameWorkspace(phase) | Scenario::DifferentWorkspace(phase) = &case.scenario
{
let blocker_target = match &case.scenario {
Scenario::DifferentWorkspace(_) => &blocker_ws,
Scenario::SameWorkspace(_) => &claimable_ws,
Scenario::NoBlocker => unreachable!(),
};
make_ticket(&store, blocker_target, "Blocker", *phase).await;
}
let id = make_ticket(
&store,
&claimable_ws,
"Claimable",
TicketPhase::ReadyForDevelopment,
)
.await;
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::ReadyForDevelopment,
TicketPhase::InDevelopment,
&claim_ws_name,
PipelineCheck::Enforce,
)
.await
.expect("claim should not error");
if expected_claim {
let claimed = claimed.expect("should claim ticket");
assert_eq!(claimed.id, id, "Case '{}': wrong ticket id", case.name);
assert_eq!(
claimed.phase,
TicketPhase::InDevelopment,
"Case '{}': wrong phase after claim",
case.name
);
} else {
assert!(
claimed.is_none(),
"Case '{}': claim should be blocked",
case.name
);
}
}
}
#[tokio::test]
async fn test_create_ticket_with_prerequisites() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let p1 = make_ticket(&store, &ws, "P1", TicketPhase::Backlog).await;
let p2 = make_ticket(&store, &ws, "P2", TicketPhase::Backlog).await;
let deps = vec![p1.clone(), p2.clone()];
let id = TicketBuilder::new(&store, &ws)
.title("Dependent")
.desc("needs both")
.prereqs(&deps)
.create()
.await
.expect("create dependent");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.prerequisites.len(), 2);
assert!(ticket.prerequisites.contains(&p1));
assert!(ticket.prerequisites.contains(&p2));
}
#[tokio::test]
async fn test_create_ticket_invalid_inputs() {
let cases = [
Case {
name: "nonexistent prerequisite",
scenario: InvalidInputScenario::NonExistent,
},
Case {
name: "self-referencing prerequisite",
scenario: InvalidInputScenario::SelfReference,
},
Case {
name: "cross-workspace prerequisite",
scenario: InvalidInputScenario::CrossWorkspace,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let ws_b = test_ws_named("/ws_b", "ws_b");
let ws_sr = test_ws_named("/ws_sr", "ws_sr");
for case in &cases {
let expected_error = match case.scenario {
InvalidInputScenario::NonExistent => "not found",
InvalidInputScenario::SelfReference => "cannot depend on itself",
InvalidInputScenario::CrossWorkspace => "Cross-workspace",
};
let seed: Option<String> = match &case.scenario {
InvalidInputScenario::NonExistent => None,
InvalidInputScenario::CrossWorkspace => {
Some(make_ticket(&store, &ws, "Existing", TicketPhase::Backlog).await)
}
InvalidInputScenario::SelfReference => {
Some(make_ticket(&store, &ws_sr, "First", TicketPhase::Backlog).await)
}
};
let target_ws = match case.scenario {
InvalidInputScenario::CrossWorkspace => &ws_b,
InvalidInputScenario::SelfReference => &ws_sr,
InvalidInputScenario::NonExistent => &ws,
};
let prereqs: Vec<String> = match &case.scenario {
InvalidInputScenario::NonExistent => vec!["nonexistent-1".to_string()],
InvalidInputScenario::CrossWorkspace => {
vec![seed.clone().expect("seed must exist for CrossWorkspace")]
}
InvalidInputScenario::SelfReference => {
vec![format!("{}-1", ws_sr.name)]
}
};
let err = TicketBuilder::new(&store, target_ws)
.title("New")
.prereqs(&prereqs)
.create()
.await
.unwrap_err();
assert!(
err.to_string().contains(expected_error),
"Case '{}': expected error containing '{}', got: {err}",
case.name,
expected_error
);
}
}
async fn create_chain_ab(store: &BoardStore, ws: Workspace) -> (String, String) {
let a = make_ticket(store, &ws, "A", TicketPhase::Backlog).await;
let b = TicketBuilder::new(store, &ws)
.title("B")
.desc("depends on A")
.prereqs(std::slice::from_ref(&a))
.create()
.await
.expect("create b");
(a, b)
}
#[tokio::test]
async fn test_circular_dependency_rejected() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let (a, b) = create_chain_ab(&store, ws.clone()).await;
let _c = TicketBuilder::new(&store, &ws)
.title("C")
.desc("depends on both")
.prereqs(&[a.clone(), b.clone()])
.create()
.await
.expect("create c — A and B as prereqs is not a cycle");
}
#[tokio::test]
async fn test_transitive_prerequisites_block() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let (a, b) = create_chain_ab(&store, ws.clone()).await;
let c = TicketBuilder::new(&store, &ws)
.title("C")
.desc("top")
.prereqs(std::slice::from_ref(&b))
.create()
.await
.expect("create c");
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
)
.await
.expect("claim")
.expect("should claim A");
assert_eq!(claimed.id, a);
let second = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
)
.await
.expect("claim");
assert!(
second.is_none(),
"B should be blocked because A is in Analysis, not Done"
);
store
.transition_to(&a, None, TicketPhase::Done, None)
.await
.expect("done a");
let claimed2 = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
)
.await
.expect("claim")
.expect("should claim B");
assert_eq!(claimed2.id, b);
store
.transition_to(&b, None, TicketPhase::Done, None)
.await
.expect("done b");
let claimed3 = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
)
.await
.expect("claim")
.expect("should claim C");
assert_eq!(claimed3.id, c);
}
async fn assert_archive_empty_db(store: &BoardStore) {
let count = store
.archive_stale_cancelled(1)
.await
.expect("archive_stale_cancelled");
assert_eq!(count, 0, "Empty DB stale archive should return 0");
let count = store
.archive_all_done_and_cancelled(None)
.await
.expect("archive_all_done_and_cancelled");
assert_eq!(count, 0, "Empty DB all archive should return 0");
}
#[tokio::test]
async fn test_archive_stale_cancelled() {
let (store, _tmp) = open_test_store().await;
assert_archive_empty_db(&store).await;
let ws = test_ws_named("/ws", "ws");
let two_hours_ago = (Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
let old_cancelled_id = make_ticket(&store, &ws, "old-cancelled", TicketPhase::Cancelled).await;
store
.conn
.execute(
"UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
crate::turso::params![two_hours_ago.clone(), old_cancelled_id.clone()],
)
.await
.expect("backdate");
let fresh_cancelled_id =
make_ticket(&store, &ws, "fresh-cancelled", TicketPhase::Cancelled).await;
let old_backlog_id = make_ticket(&store, &ws, "old-backlog", TicketPhase::Backlog).await;
store
.conn
.execute(
"UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
crate::turso::params![two_hours_ago.clone(), old_backlog_id.clone()],
)
.await
.expect("backdate");
let count = store
.archive_stale_cancelled(1)
.await
.expect("archive_stale_cancelled");
assert_eq!(count, 1, "should archive only the old cancelled ticket");
let old_cancelled = crate::util::test::expect_ticket(&store, &old_cancelled_id).await;
assert!(
old_cancelled.is_archived,
"old cancelled ticket should be archived"
);
assert_eq!(old_cancelled.phase, TicketPhase::Cancelled);
let fresh_cancelled = crate::util::test::expect_ticket(&store, &fresh_cancelled_id).await;
assert!(
!fresh_cancelled.is_archived,
"fresh cancelled ticket should NOT be archived"
);
assert_eq!(fresh_cancelled.phase, TicketPhase::Cancelled);
let old_backlog = crate::util::test::expect_ticket(&store, &old_backlog_id).await;
assert!(
!old_backlog.is_archived,
"old non-cancelled ticket should NOT be archived"
);
assert_eq!(old_backlog.phase, TicketPhase::Backlog);
}
#[tokio::test]
async fn test_archive_all_done_and_cancelled() {
let (store, _tmp) = open_test_store().await;
assert_archive_empty_db(&store).await;
let ws = test_ws_named("/ws", "ws");
let done_id = make_ticket(&store, &ws, "done", TicketPhase::Done).await;
let cancelled_id = make_ticket(&store, &ws, "cancelled", TicketPhase::Cancelled).await;
let backlog_id = make_ticket(&store, &ws, "backlog", TicketPhase::Backlog).await;
let count_done_before = store
.count_by_phase(TicketPhase::Done, None)
.await
.expect("count Done before");
assert_eq!(
count_done_before, 1,
"Should count Done ticket before archive"
);
let count_cancelled_before = store
.count_by_phase(TicketPhase::Cancelled, None)
.await
.expect("count Cancelled before");
assert_eq!(
count_cancelled_before, 1,
"Should count Cancelled ticket before archive"
);
let count_backlog_before = store
.count_by_phase(TicketPhase::Backlog, None)
.await
.expect("count Backlog before");
assert_eq!(
count_backlog_before, 1,
"Should count Backlog ticket before archive"
);
let count = store
.archive_all_done_and_cancelled(None)
.await
.expect("archive");
assert_eq!(count, 2, "should archive Done and Cancelled tickets");
let done_ticket = crate::util::test::expect_ticket(&store, &done_id).await;
assert!(done_ticket.is_archived, "Done ticket should be archived");
assert_eq!(done_ticket.phase, TicketPhase::Done);
let cancelled_ticket = crate::util::test::expect_ticket(&store, &cancelled_id).await;
assert!(
cancelled_ticket.is_archived,
"Cancelled ticket should be archived"
);
assert_eq!(cancelled_ticket.phase, TicketPhase::Cancelled);
let backlog_ticket = crate::util::test::expect_ticket(&store, &backlog_id).await;
assert!(
!backlog_ticket.is_archived,
"Backlog ticket should NOT be archived"
);
assert_eq!(backlog_ticket.phase, TicketPhase::Backlog);
let count_done_after = store
.count_by_phase(TicketPhase::Done, None)
.await
.expect("count Done after");
assert_eq!(
count_done_after, 0,
"Should not count archived Done tickets"
);
let count_cancelled_after = store
.count_by_phase(TicketPhase::Cancelled, None)
.await
.expect("count Cancelled after");
assert_eq!(
count_cancelled_after, 0,
"Should not count archived Cancelled tickets"
);
let count_backlog_after = store
.count_by_phase(TicketPhase::Backlog, None)
.await
.expect("count Backlog after");
assert_eq!(
count_backlog_after, 1,
"Should still count non-archived Backlog tickets"
);
}
#[tokio::test]
async fn test_archive_all_done_and_cancelled_workspace_filter() {
let (store, _tmp) = open_test_store().await;
let id1 = make_ticket(
&store,
&test_ws_named("/ws1", "ws1"),
"Test",
TicketPhase::Done,
)
.await;
let id2 = make_ticket(
&store,
&test_ws_named("/ws2", "ws2"),
"Test",
TicketPhase::Done,
)
.await;
let count = store
.archive_all_done_and_cancelled(Some("ws1"))
.await
.expect("archive_all_done_and_cancelled");
assert_eq!(count, 1, "Should archive only ws1 ticket");
let ticket1 = crate::util::test::expect_ticket(&store, &id1).await;
assert!(ticket1.is_archived, "ws1 ticket should be archived");
assert_eq!(
ticket1.phase,
TicketPhase::Done,
"ws1 phase should remain Done"
);
let ticket2 = crate::util::test::expect_ticket(&store, &id2).await;
assert!(!ticket2.is_archived, "ws2 ticket should NOT be archived");
assert_eq!(
ticket2.phase,
TicketPhase::Done,
"ws2 ticket should remain Done"
);
}
#[tokio::test]
async fn test_create_ticket_tool_with_prerequisites() {
crate::util::test::init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws("/tmp/test_ws_tool_prereqs");
let p_id = make_ticket(store, &ws, "Pre", TicketPhase::Backlog).await;
let tool = crate::tools::CreateTicketTool::new("test");
let args = serde_json::json!({
"title": "Test with prereqs",
"description": "depends on something",
"prerequisites": [p_id],
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains(&p_id),
"Output should mention prerequisite ID"
);
}
#[tokio::test]
async fn test_supersede_and_create_basic() {
init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws_named("/ws", "ws");
let old_id = make_ticket(store, &ws, "Test", TicketPhase::Backlog).await;
let new_id = TicketBuilder::new(store, &ws)
.title("New title")
.desc("New desc")
.supersede(&old_id)
.await
.expect("supersede");
let old = expect_ticket(store, &old_id).await;
assert_superseded_ticket(&old);
assert_eq!(
old.superseded_by.as_deref(),
Some(new_id.as_str()),
"superseded ticket should point to the new ticket"
);
let new = expect_ticket(store, &new_id).await;
assert_eq!(new.phase, TicketPhase::Backlog);
assert_eq!(new.supersedes.as_deref(), Some(old_id.as_str()));
assert_eq!(new.title, "New title");
}
#[tokio::test]
async fn test_supersede_rewires_only_matching_prerequisite() {
init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws_named("/ws", "ws");
let a_id = make_ticket(store, &ws, "A", TicketPhase::Backlog).await;
let c_id = make_ticket(store, &ws, "C", TicketPhase::Backlog).await;
let b_id = TicketBuilder::new(store, &ws)
.title("B")
.desc("dep on A and C")
.prereqs(&[a_id.clone(), c_id.clone()])
.create()
.await
.expect("create B");
let d_id = make_ticket(store, &ws, "D", TicketPhase::Backlog).await;
let supersede_id = TicketBuilder::new(store, &ws)
.title("A2")
.desc("refined")
.supersede(&a_id)
.await
.expect("supersede");
let b = store
.get_ticket(&b_id)
.await
.expect("get B")
.expect("B exists");
assert_eq!(b.prerequisites, vec![supersede_id.clone(), c_id.clone()]);
let d = store
.get_ticket(&d_id)
.await
.expect("get D")
.expect("D exists");
assert!(d.prerequisites.is_empty());
}
#[tokio::test]
async fn test_supersede_invalid_inputs() {
let cases = [
Case {
name: "nonexistent original",
scenario: InvalidInputScenario::NonExistent,
},
Case {
name: "cross-workspace supersede",
scenario: InvalidInputScenario::CrossWorkspace,
},
Case {
name: "self-referencing prerequisites",
scenario: InvalidInputScenario::SelfReference,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let ws_b = test_ws_named("/ws_b", "ws_b");
for case in &cases {
let expected_error = match case.scenario {
InvalidInputScenario::NonExistent => "not found",
InvalidInputScenario::CrossWorkspace => "Cross-workspace",
InvalidInputScenario::SelfReference => "supersede and depend",
};
let original_id = match case.scenario {
InvalidInputScenario::NonExistent => None,
InvalidInputScenario::CrossWorkspace | InvalidInputScenario::SelfReference => {
let id = make_ticket(&store, &ws, "A", TicketPhase::Backlog).await;
Some(id)
}
};
let target_ws = match case.scenario {
InvalidInputScenario::CrossWorkspace => &ws_b,
InvalidInputScenario::NonExistent | InvalidInputScenario::SelfReference => &ws,
};
let supersede_id: &str = original_id.as_deref().unwrap_or("nonexistent");
let prereqs: Vec<String> = match &case.scenario {
InvalidInputScenario::SelfReference => {
vec![
original_id
.clone()
.expect("original must exist for SelfReference"),
]
}
InvalidInputScenario::NonExistent | InvalidInputScenario::CrossWorkspace => vec![],
};
let err = TicketBuilder::new(&store, target_ws)
.title("New")
.prereqs(&prereqs)
.supersede(supersede_id)
.await
.unwrap_err();
assert!(
err.to_string().contains(expected_error),
"Case '{}': expected error containing '{}', got: {err}",
case.name,
expected_error
);
}
}
#[tokio::test]
async fn test_supersede_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws("/tmp/test_ws_supersede_tool");
let old_id = make_ticket(store, &ws, "Old", TicketPhase::Backlog).await;
let tool = crate::tools::CreateTicketTool::new("test");
let args = serde_json::json!({
"title": "Refined",
"description": "refined desc",
"supersede": old_id,
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains("Superseded"),
"Output should say Superseded: {result}"
);
assert!(
result.contains(&old_id),
"Output should mention old ID: {result}"
);
let old = expect_ticket(store, &old_id).await;
assert_superseded_ticket(&old);
}
#[tokio::test]
async fn test_transactional_triple_write() {
for should_commit in [false, true] {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let id = make_ticket(&store, &ws, "Test", TicketPhase::QaPassed).await;
let tx = store.conn.begin_tx().await.unwrap();
BoardStore::finalize_done_tx(
&tx,
&id,
"abcdef0123456789abcdef0123456789abcd0123",
10,
5,
"triple write comment",
TicketPhase::QaPassed,
)
.await
.unwrap();
let label = if should_commit { "commit" } else { "rollback" };
if should_commit {
tx.commit().await.unwrap();
} else {
tx.rollback().await.unwrap();
}
let ticket = crate::util::test::expect_ticket(&store, &id).await;
let comments = store.get_comments(&id).await.expect("get comments");
if should_commit {
assert_eq!(
ticket.commit_hash.as_deref(),
Some("abcdef0123456789abcdef0123456789abcd0123"),
"({label}) commit_hash",
);
assert_eq!(ticket.lines_added, Some(10), "({label}) lines_added");
assert_eq!(ticket.lines_removed, Some(5), "({label}) lines_removed");
assert_eq!(ticket.phase, TicketPhase::Done, "({label}) phase");
assert_eq!(comments.len(), 1, "({label}) comments.len");
assert_eq!(
comments[0].content, "triple write comment",
"({label}) comment content"
);
} else {
assert_eq!(
ticket.commit_hash, None,
"({label}) commit_hash after rollback"
);
assert_eq!(
ticket.lines_added, None,
"({label}) lines_added after rollback"
);
assert_eq!(
ticket.lines_removed, None,
"({label}) lines_removed after rollback"
);
assert_eq!(
ticket.phase,
TicketPhase::QaPassed,
"({label}) phase after rollback",
);
assert_eq!(comments.len(), 0, "({label}) comments.len after rollback");
}
}
}
#[test]
fn test_parse_prereqs() {
let valid: &[(&str, &[&str])] = &[
("[]", &[] as &[&str]),
(r#"["a","b","c"]"#, &["a", "b", "c"]),
];
for (input, expected) in valid {
let got = parse_prereqs(input).expect("should parse valid JSON");
assert_eq!(got, *expected, "input: {input:?}");
}
let invalid: &[&str] = &["", "not valid json {{{", r#"{"key":"value"}"#, "[1, 2, 3]"];
for input in invalid {
let err = parse_prereqs(input).unwrap_err();
assert!(
err.to_string().contains("Corrupt prerequisites JSON"),
"input {input:?}: expected 'Corrupt prerequisites JSON' error, got: {err}",
);
}
let long = format!(r#""{}...""#, "x".repeat(500));
let msg = parse_prereqs(&long).unwrap_err().to_string();
assert!(
msg.contains('…'),
"long input should produce truncated preview: {msg}"
);
assert!(
msg.len() < 500,
"truncated message should be <500 chars, got len={}",
msg.len()
);
let raw = format!("{}éééééééééémore", "x".repeat(199));
assert!(raw.len() > 200, "need raw longer than 200 chars");
assert!(
!raw.is_char_boundary(200),
"byte 200 must be mid-character for this test to be meaningful"
);
let msg = parse_prereqs(&raw).unwrap_err().to_string();
assert!(
msg.contains('…'),
"multi-byte input should produce truncated preview: {msg}"
);
assert!(
msg.len() < raw.len() + 50,
"message too long after truncation: len={}, raw.len()={}",
msg.len(),
raw.len()
);
assert!(
msg.contains("Corrupt prerequisites JSON"),
"should mention corrupt JSON: {msg}"
);
}
#[tokio::test]
async fn corrupt_prerequisites_causes_query_errors() {
let (store, _tmp, id) = setup().await;
store
.conn
.execute(
"UPDATE tickets SET prerequisites = ?1 WHERE id = ?2",
crate::turso::params!["{not valid json}", id.clone()],
)
.await
.expect("corrupt update");
let result = store.get_ticket(&id).await;
assert!(
result.is_err(),
"get_ticket should fail when prerequisites are corrupt"
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("Corrupt prerequisites JSON"),
"error should mention corrupt JSON: {msg}"
);
assert!(
msg.contains(&id),
"error should include ticket ID {id}: {msg}"
);
let result = store.list_all_tickets(Some("ws"), None).await;
assert!(
result.is_err(),
"list_all_tickets should fail when any ticket has corrupt prerequisites"
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("Corrupt prerequisites JSON"),
"list_all_tickets error should mention corrupt JSON: {msg}"
);
assert!(
msg.contains(&id),
"list_all_tickets error should include ticket ID {id}: {msg}"
);
}
#[tokio::test]
async fn test_claim_diagnostics() {
enum Scenario {
Success,
AlreadyAssigned,
WrongPhase,
}
struct Case {
name: &'static str,
scenario: Scenario,
}
let cases = [
Case {
name: "unassigned in diagnostics succeeds",
scenario: Scenario::Success,
},
Case {
name: "already assigned fails",
scenario: Scenario::AlreadyAssigned,
},
Case {
name: "wrong phase fails",
scenario: Scenario::WrongPhase,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
for (i, case) in cases.iter().enumerate() {
let title = format!("claim-{i}");
let phase = if matches!(case.scenario, Scenario::WrongPhase) {
TicketPhase::Backlog
} else {
TicketPhase::InDiagnostics
};
let id = make_ticket(&store, &ws, &title, phase).await;
if matches!(case.scenario, Scenario::AlreadyAssigned) {
store
.set_assigned_to(&id, Some(DIAGNOSTICS_ROLE))
.await
.expect("set_assigned_to");
}
let claimed = store
.claim_diagnostics(&id, DIAGNOSTICS_ROLE)
.await
.expect("claim_diagnostics");
match case.scenario {
Scenario::Success => {
assert!(claimed, "Case '{}': expected claim to succeed", case.name);
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(
ticket.assigned_to.as_deref(),
Some(DIAGNOSTICS_ROLE),
"Case '{}': assignee should be set",
case.name
);
assert_eq!(
ticket.phase,
TicketPhase::InDiagnostics,
"Case '{}': phase should remain InDiagnostics",
case.name
);
let second = store
.claim_diagnostics(&id, DIAGNOSTICS_ROLE)
.await
.expect("second claim");
assert!(
!second,
"Case '{}': second claim should return false (idempotent)",
case.name
);
}
Scenario::AlreadyAssigned | Scenario::WrongPhase => {
assert!(!claimed, "Case '{}': expected claim to fail", case.name);
}
}
}
}
#[tokio::test]
async fn test_claim_sanitation() {
struct Case {
name: &'static str,
phase: TicketPhase,
expected_claim: bool,
}
let cases = [
Case {
name: "qa_passed succeeds",
phase: TicketPhase::QaPassed,
expected_claim: true,
},
Case {
name: "backlog (wrong phase) fails",
phase: TicketPhase::Backlog,
expected_claim: false,
},
Case {
name: "in_development (wrong phase) fails",
phase: TicketPhase::InDevelopment,
expected_claim: false,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
for (i, case) in cases.iter().enumerate() {
let title = format!("san-claim-{i}");
let id = make_ticket(&store, &ws, &title, case.phase).await;
let expected_key =
crate::session::ticket_session_key(&id, crate::Role::Sanitation.as_str());
let claimed = store
.claim_sanitation(&id, &expected_key)
.await
.expect("claim_sanitation");
assert_eq!(
claimed, case.expected_claim,
"Case '{}': unexpected claim result",
case.name
);
if case.expected_claim {
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.phase, TicketPhase::InSanitation);
assert_eq!(
ticket.assigned_to.as_deref(),
Some(expected_key.as_str()),
"Case '{}': assigned_to should be set to sanitation session key",
case.name
);
}
}
}
#[tokio::test]
async fn test_claim_sanitation_workspace_serialization() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let first_id = make_ticket(&store, &ws, "First", TicketPhase::QaPassed).await;
let second_id = make_ticket(&store, &ws, "Second", TicketPhase::QaPassed).await;
let first_key = crate::session::ticket_session_key(&first_id, crate::Role::Sanitation.as_str());
let second_key =
crate::session::ticket_session_key(&second_id, crate::Role::Sanitation.as_str());
let first_claimed = store
.claim_sanitation(&first_id, &first_key)
.await
.expect("first claim");
assert!(first_claimed, "first claim should succeed");
let second_claimed = store
.claim_sanitation(&second_id, &second_key)
.await
.expect("second claim");
assert!(
!second_claimed,
"second claim should be blocked while first ticket is in sanitation pipeline"
);
store
.transition_to(&first_id, None, TicketPhase::Done, None)
.await
.expect("transition first to Done (clears sanitation pipeline)");
let second_claimed_retry = store
.claim_sanitation(&second_id, &second_key)
.await
.expect("second claim retry");
assert!(
second_claimed_retry,
"second claim should succeed after pipeline clears"
);
}
#[tokio::test]
async fn test_claim_sanitation_cross_workspace_serialization() {
let (store, _tmp) = open_test_store().await;
let ws_a = test_ws_named("/ws_a", "ws_a");
let ws_b = test_ws_named("/ws_b", "ws_b");
let id_a = make_ticket(&store, &ws_a, "Workspace A", TicketPhase::QaPassed).await;
let id_b = make_ticket(&store, &ws_b, "Workspace B", TicketPhase::QaPassed).await;
let key_a = crate::session::ticket_session_key(&id_a, crate::Role::Sanitation.as_str());
let key_b = crate::session::ticket_session_key(&id_b, crate::Role::Sanitation.as_str());
let claimed_a = store
.claim_sanitation(&id_a, &key_a)
.await
.expect("claim a");
assert!(claimed_a, "workspace A claim should succeed");
let claimed_b = store
.claim_sanitation(&id_b, &key_b)
.await
.expect("claim b");
assert!(
claimed_b,
"workspace B claim should succeed independently of workspace A"
);
let ticket_a = crate::util::test::expect_ticket(&store, &id_a).await;
assert_eq!(ticket_a.phase, TicketPhase::InSanitation);
let ticket_b = crate::util::test::expect_ticket(&store, &id_b).await;
assert_eq!(ticket_b.phase, TicketPhase::InSanitation);
}
#[tokio::test]
async fn test_set_assigned_to_none() {
let (store, _tmp, id) = setup().await;
store
.set_assigned_to(&id, Some(DIAGNOSTICS_ROLE))
.await
.expect("set_assigned_to");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.assigned_to.as_deref(), Some(DIAGNOSTICS_ROLE));
store
.set_assigned_to(&id, None)
.await
.expect("set_assigned_to(None) should clear assignee");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert!(ticket.assigned_to.is_none(), "assigned_to should be NULL");
store
.set_assigned_to(&id, None)
.await
.expect("second set_assigned_to(None) should also succeed");
let (store2, _tmp2) = open_test_store().await;
let result = store2.set_assigned_to("nonexistent", None).await;
assert!(
result.is_err(),
"set_assigned_to(None) on nonexistent ticket should fail"
);
}
#[allow(clippy::too_many_lines)]
#[tokio::test]
async fn test_ticket_roundtrip_all_fields() {
let (store, _tmp) = open_test_store().await;
let none = store.get_ticket("nonexistent").await.expect("get");
assert!(none.is_none(), "non-existent ticket should return None");
let ws = crate::workspace::test_ws_named("/test_ws", "test_workspace");
let id = TicketBuilder::new(&store, &ws)
.title("Roundtrip Title")
.desc("Roundtrip description")
.phase(TicketPhase::Backlog)
.reporter("test_reporter")
.create()
.await
.expect("create_ticket");
let fresh = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists");
assert_eq!(fresh.title, "Roundtrip Title", "fresh title");
assert_eq!(
fresh.description, "Roundtrip description",
"fresh description"
);
assert_eq!(fresh.phase, TicketPhase::Backlog, "fresh phase");
assert!(
fresh.assigned_to.is_none(),
"fresh ticket should have no assigned_to"
);
assert!(
fresh.comments.is_empty(),
"fresh ticket should have no comments"
);
assert!(
fresh.commit_hash.is_none(),
"fresh ticket should have no commit_hash"
);
assert!(
fresh.lines_added.is_none(),
"fresh ticket should have no lines_added"
);
assert!(
fresh.lines_removed.is_none(),
"fresh ticket should have no lines_removed"
);
assert_eq!(
fresh.workspace_name, "test_workspace",
"fresh workspace_name"
);
assert!(
fresh.created_at.contains('T'),
"fresh created_at should be RFC 3339: {}",
fresh.created_at,
);
assert!(
fresh.updated_at.contains('T'),
"fresh updated_at should be RFC 3339: {}",
fresh.updated_at,
);
assert_eq!(fresh.reporter, "test_reporter", "fresh reporter");
assert!(
fresh.prerequisites.is_empty(),
"fresh prerequisites should be empty"
);
assert!(
fresh.supersedes.is_none(),
"fresh supersedes should be None"
);
assert!(
fresh.superseded_by.is_none(),
"fresh superseded_by should be None"
);
assert!(!fresh.is_archived, "fresh is_archived should be false");
assert!(
!fresh.pipeline_reservation,
"fresh pipeline_reservation should be false"
);
store
.set_assigned_to(&id, Some("test_assignee"))
.await
.expect("set_assigned_to");
let tx = store.conn.begin_tx().await.unwrap();
BoardStore::set_commit_info_tx(&tx, &id, "abcdef0123456789abcdef0123456789abcd0123", 42, 7)
.await
.expect("set_commit_info_tx");
tx.commit().await.unwrap();
let ticket = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists");
assert_eq!(ticket.id, id, "id mismatch");
assert_eq!(ticket.title, "Roundtrip Title", "title mismatch");
assert_eq!(
ticket.description, "Roundtrip description",
"description mismatch",
);
assert_eq!(ticket.phase, TicketPhase::Backlog, "phase mismatch");
assert_eq!(
ticket.assigned_to.as_deref(),
Some("test_assignee"),
"assigned_to should round-trip",
);
assert_eq!(ticket.workspace_name, "test_workspace");
assert!(
ticket.created_at.contains('T'),
"created_at should be RFC 3339: {}",
ticket.created_at,
);
assert!(
ticket.updated_at.contains('T'),
"updated_at should be RFC 3339: {}",
ticket.updated_at,
);
assert!(ticket.comments.is_empty(), "no comments expected");
assert!(
ticket.prerequisites.is_empty(),
"prerequisites should round-trip as empty",
);
assert_eq!(
ticket.commit_hash.as_deref(),
Some("abcdef0123456789abcdef0123456789abcd0123"),
"commit_hash mismatch",
);
assert_eq!(ticket.lines_added, Some(42), "lines_added mismatch");
assert_eq!(ticket.lines_removed, Some(7), "lines_removed mismatch");
assert_eq!(ticket.reporter, "test_reporter", "reporter mismatch");
assert!(
ticket.supersedes.is_none(),
"supersedes should be None for simple ticket",
);
assert!(
ticket.superseded_by.is_none(),
"superseded_by should be None for simple ticket",
);
assert!(
!ticket.is_archived,
"is_archived should be false before archiving",
);
assert!(
!ticket.pipeline_reservation,
"pipeline_reservation should be false for fresh ticket",
);
store.set_archived(&id).await.expect("set_archived");
let archived = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists after archive");
assert!(
archived.is_archived,
"is_archived should be true after set_archived"
);
assert!(
archived.assigned_to.is_none(),
"assigned_to should be cleared after archive",
);
}
async fn create_archived_ticket(
store: &super::BoardStore,
title: &str,
workspace_name: &str,
) -> String {
let ws = test_ws(workspace_name);
let id = make_ticket(store, &ws, title, crate::board::TicketPhase::Done).await;
store.set_archived(&id).await.expect("set_archived");
id
}
#[tokio::test]
async fn test_search_archived_by_fts_finds_matching_title() {
let (store, _tmp) = open_test_store().await;
let id = create_archived_ticket(&store, "Fix network timeout bug", "ws1").await;
let results = store
.search_archived_by_fts("network timeout", 10)
.await
.expect("FTS search");
assert!(!results.is_empty(), "should find the ticket");
let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
assert!(
ids.contains(&id.as_str()),
"result should contain our ticket"
);
}
#[tokio::test]
async fn test_search_archived_by_fts_excludes_non_archived() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws("ws2");
make_ticket(
&store,
&ws,
"Still active",
crate::board::TicketPhase::Backlog,
)
.await;
let results = store
.search_archived_by_fts("active", 10)
.await
.expect("FTS search");
assert!(results.is_empty(), "non-archived ticket should not appear");
}
#[tokio::test]
async fn test_search_archived_by_fts_punctuation_only() {
let (store, _tmp) = open_test_store().await;
let results = store
.search_archived_by_fts("!@#$%", 10)
.await
.expect("FTS search");
assert!(
results.is_empty(),
"query with only punctuation should produce no matches"
);
}
#[tokio::test]
async fn test_detailed_display_basic() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/test-workspace", "test-ws");
let prereq_id = make_ticket(&store, &ws, "Prereq", TicketPhase::Backlog).await;
let id = TicketBuilder::new(&store, &ws)
.title("Display Test Ticket")
.desc("A description for testing")
.phase(TicketPhase::InDevelopment)
.prereqs(std::slice::from_ref(&prereq_id))
.reporter("manager")
.create()
.await
.expect("create");
let ticket = expect_ticket(&store, &id).await;
let display = ticket.detailed_display();
assert!(
display.contains(&format!("Ticket: {id}")),
"should contain ticket id"
);
assert!(
display.contains("Title: Display Test Ticket"),
"should contain title"
);
assert!(
display.contains("Description: A description for testing"),
"should contain description"
);
assert!(
display.contains("Phase: in_development"),
"should use snake_case phase"
);
assert!(
display.contains("Reporter: manager"),
"should contain reporter"
);
assert!(
display.contains("Workspace: test-ws"),
"should contain workspace"
);
assert!(
display.contains("Created:"),
"should contain created timestamp"
);
assert!(
display.contains("Updated:"),
"should contain updated timestamp"
);
assert!(
display.contains(&format!("Prerequisites: {prereq_id}")),
"should show prerequisites"
);
assert!(
display.contains("Comments:"),
"should have comments section"
);
assert!(display.contains("(no comments)"), "should show no comments");
assert!(
!display.contains("Supersedes:"),
"no supersedes when not set"
);
assert!(
!display.contains("Superseded by:"),
"no superseded_by when not set"
);
assert!(
!display.contains("Archived:"),
"no archived line when false"
);
assert!(
!display.contains("assigned_to:"),
"assigned_to should not be displayed"
);
assert!(
!display.contains("commit_hash:"),
"commit_hash should not be displayed"
);
assert!(
!display.contains("lines_added:"),
"lines_added should not be displayed"
);
assert!(
!display.contains("lines_removed:"),
"lines_removed should not be displayed"
);
}
#[tokio::test]
async fn test_detailed_display_with_content() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/test-workspace", "test-ws");
let id = make_ticket(&store, &ws, "Comment Test", TicketPhase::Backlog).await;
store
.add_comment(&id, Role::Analyst.as_str(), "First comment")
.await
.expect("add_comment");
store
.add_comment(&id, Role::Reviewer.as_str(), "Second comment")
.await
.expect("add_comment");
let ticket = expect_ticket(&store, &id).await;
let display = ticket.detailed_display();
assert!(
display.contains("Comments:"),
"should have comments section"
);
assert!(display.contains("[analyst]"), "should show analyst role");
assert!(display.contains("[reviewer]"), "should show reviewer role");
assert!(
display.contains("First comment"),
"should show first comment"
);
assert!(
display.contains("Second comment"),
"should show second comment"
);
assert!(
!display.contains("(no comments)"),
"should not say 'no comments' when comments exist"
);
let pre_a = make_ticket(&store, &ws, "Pre-A", TicketPhase::Backlog).await;
let pre_b = make_ticket(&store, &ws, "Pre-B", TicketPhase::Backlog).await;
let pre_c = make_ticket(&store, &ws, "Pre-C", TicketPhase::Backlog).await;
let multi_id = TicketBuilder::new(&store, &ws)
.title("Multi prereq")
.prereqs(&[pre_a.clone(), pre_b.clone(), pre_c.clone()])
.create()
.await
.expect("create");
let ticket = expect_ticket(&store, &multi_id).await;
let display = ticket.detailed_display();
assert!(
display.contains(&format!("Prerequisites: {pre_a}, {pre_b}, {pre_c}")),
"should show all prerequisites joined with comma+space"
);
}
#[tokio::test]
async fn test_detailed_display_supersedes_chain() {
init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws_named("/ws", "ws");
let old_id = make_ticket(store, &ws, "Old ticket", TicketPhase::Backlog).await;
let new_id = TicketBuilder::new(store, &ws)
.title("New ticket")
.desc("new desc")
.supersede(&old_id)
.await
.expect("supersede");
let new_ticket = expect_ticket(store, &new_id).await;
let new_display = new_ticket.detailed_display();
assert!(
new_display.contains(&format!("Supersedes: {old_id}")),
"new ticket should show Supersedes: old_id"
);
let old_ticket = expect_ticket(store, &old_id).await;
let old_display = old_ticket.detailed_display();
assert!(
old_display.contains(&format!("Superseded by: {new_id}")),
"old ticket should show Superseded by: new_id"
);
assert!(
old_display.contains("Archived: yes"),
"old ticket should be archived"
);
}
#[tokio::test]
async fn test_list_archived_with_embeddings_returns_deserialized() {
let (store, _tmp) = open_test_store().await;
{
let candidates = store.list_archived_with_embeddings().await.expect("list");
assert!(candidates.is_empty(), "no tickets at all");
}
let ws = test_ws("ws");
let embedding: Vec<f32> = vec![1.0, 2.0];
let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
let id = TicketBuilder::new(&store, &ws)
.title("Embedded ticket")
.phase(crate::board::TicketPhase::Done)
.embedding(&blob)
.create()
.await
.expect("create_ticket with embedding");
store.set_archived(&id).await.expect("archive");
let candidates = store.list_archived_with_embeddings().await.expect("list");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].0, id);
assert_eq!(candidates[0].1, vec![1.0, 2.0]);
}