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;
#[derive(Debug, Clone, Copy)]
enum InvalidInputScenario {
NonExistent,
CrossWorkspace,
SelfReference,
}
#[derive(Debug, Clone, Copy)]
enum InvalidInputOp {
Create,
Supersede,
}
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,
None,
)
.await
.expect("claim")
.expect("ticket exists");
store
.set_assigned_to_no_cancel(&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]
#[serial_test::serial(reset_inflight)]
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,
None,
)
.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_terminal_transition_clears_reservation() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let id = make_ticket(&store, &ws, "Bounced", TicketPhase::Backlog).await;
store
.transition_to(
&id,
Some(TicketPhase::Backlog),
TicketPhase::ReadyForDevelopment,
Some(true),
)
.await
.expect("reserve");
assert!(
expect_ticket(&store, &id).await.pipeline_reservation,
"bounce-back should set reservation"
);
store
.transition_to(&id, None, TicketPhase::Done, None)
.await
.expect("done");
let t = expect_ticket(&store, &id).await;
assert_eq!(t.phase, TicketPhase::Done);
assert!(
!t.pipeline_reservation,
"terminal transition must clear pipeline_reservation"
);
let ctl = make_ticket(&store, &ws, "Control", TicketPhase::Backlog).await;
store
.transition_to(
&ctl,
Some(TicketPhase::Backlog),
TicketPhase::ReadyForDevelopment,
Some(true),
)
.await
.expect("reserve control");
store
.transition_to(&ctl, None, TicketPhase::Planning, None)
.await
.expect("planning");
let ctl = expect_ticket(&store, &ctl).await;
assert_eq!(ctl.phase, TicketPhase::Planning);
assert!(
ctl.pipeline_reservation,
"non-terminal transition must preserve pipeline_reservation"
);
}
#[tokio::test]
async fn test_supersede_clears_reservation() {
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;
store
.transition_to(
&old_id,
Some(TicketPhase::Backlog),
TicketPhase::ReadyForDevelopment,
Some(true),
)
.await
.expect("reserve");
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!(
!old.pipeline_reservation,
"supersede cancellation must clear pipeline_reservation"
);
}
#[tokio::test]
async fn test_clear_terminal_reservations_sweep() {
let (store, _tmp) = open_test_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 failed_id = make_ticket(&store, &ws, "Failed", TicketPhase::Failed).await;
let archived_id = make_ticket(&store, &ws, "Archived", TicketPhase::Done).await;
store.set_archived(&archived_id).await.expect("archive");
for id in [&done_id, &cancelled_id, &failed_id, &archived_id] {
store
.conn
.execute(
"UPDATE tickets SET pipeline_reservation = 1 WHERE id = ?1",
crate::turso::params![id.clone()],
)
.await
.expect("stale reservation");
}
let archived_before = expect_ticket(&store, &archived_id).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("reserve");
assert_eq!(
store.clear_terminal_reservations().await.expect("sweep"),
4,
"sweep should clear all four stale terminal rows"
);
for id in [&done_id, &cancelled_id, &failed_id, &archived_id] {
assert!(
!expect_ticket(&store, id).await.pipeline_reservation,
"sweep must clear reservation on {id}"
);
}
let archived_after = expect_ticket(&store, &archived_id).await;
assert_eq!(
archived_after.updated_at, archived_before.updated_at,
"sweep must not bump updated_at"
);
assert!(
expect_ticket(&store, &reserved_id)
.await
.pipeline_reservation,
"sweep must not touch non-terminal reserved tickets"
);
}
#[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,
None,
)
.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,
None,
)
.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,
None,
)
.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");
}
#[tokio::test]
async fn test_claim_ticket_in_workspace_respects_claim_grace() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let fresh = make_ticket(&store, &ws, "Fresh", TicketPhase::Backlog).await;
assert!(
store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
Some(chrono::Duration::seconds(60)),
)
.await
.expect("claim")
.is_none(),
"fresh ticket must stay in backlog within the claim grace window"
);
let old_created = (Utc::now() - chrono::Duration::seconds(120)).to_rfc3339();
store
.conn
.execute(
"UPDATE tickets SET created_at = ?1 WHERE id = ?2",
crate::turso::params![old_created, fresh.clone()],
)
.await
.expect("backdate");
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
Some(chrono::Duration::seconds(60)),
)
.await
.expect("claim")
.expect("old ticket should be claimable");
assert_eq!(claimed.id, fresh);
let fresh2 = make_ticket(&store, &ws, "Fresh2", TicketPhase::Backlog).await;
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"ws",
PipelineCheck::Skip,
None,
)
.await
.expect("claim")
.expect("fresh ticket claimable without grace window");
assert_eq!(claimed.id, fresh2);
}
#[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,
None,
)
.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_invalid_inputs() {
let cases = [
(InvalidInputOp::Create, InvalidInputScenario::NonExistent),
(InvalidInputOp::Create, InvalidInputScenario::CrossWorkspace),
(InvalidInputOp::Create, InvalidInputScenario::SelfReference),
(InvalidInputOp::Supersede, InvalidInputScenario::NonExistent),
(
InvalidInputOp::Supersede,
InvalidInputScenario::CrossWorkspace,
),
(
InvalidInputOp::Supersede,
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");
let ws_sr = test_ws_named("/ws_sr", "ws_sr");
for (op, scenario) in cases {
let expected_error = match (op, scenario) {
(_, InvalidInputScenario::NonExistent) => "not found",
(_, InvalidInputScenario::CrossWorkspace) => "Cross-workspace",
(InvalidInputOp::Create, InvalidInputScenario::SelfReference) => {
"cannot depend on itself"
}
(InvalidInputOp::Supersede, InvalidInputScenario::SelfReference) => {
"supersede and depend"
}
};
let seed: Option<String> = match scenario {
InvalidInputScenario::NonExistent => None,
InvalidInputScenario::CrossWorkspace => {
Some(make_ticket(&store, &ws, "Existing", TicketPhase::Backlog).await)
}
InvalidInputScenario::SelfReference => {
let seed_ws = match op {
InvalidInputOp::Create => &ws_sr,
InvalidInputOp::Supersede => &ws,
};
Some(make_ticket(&store, seed_ws, "Original", TicketPhase::Backlog).await)
}
};
let target_ws = match (op, scenario) {
(InvalidInputOp::Create, InvalidInputScenario::SelfReference) => &ws_sr,
(_, InvalidInputScenario::CrossWorkspace) => &ws_b,
(_, InvalidInputScenario::NonExistent)
| (InvalidInputOp::Supersede, InvalidInputScenario::SelfReference) => &ws,
};
let prereqs: Vec<String> = match (op, scenario) {
(InvalidInputOp::Create, InvalidInputScenario::NonExistent) => {
vec!["nonexistent-1".to_string()]
}
(InvalidInputOp::Create, InvalidInputScenario::SelfReference) => {
vec![format!("{}-1", ws_sr.name)]
}
(
InvalidInputOp::Supersede,
InvalidInputScenario::NonExistent | InvalidInputScenario::CrossWorkspace,
) => vec![],
(InvalidInputOp::Create, InvalidInputScenario::CrossWorkspace)
| (InvalidInputOp::Supersede, InvalidInputScenario::SelfReference) => {
vec![seed.clone().expect("seed")]
}
};
let err = match op {
InvalidInputOp::Create => TicketBuilder::new(&store, target_ws)
.title("New")
.prereqs(&prereqs)
.create()
.await
.unwrap_err(),
InvalidInputOp::Supersede => {
let supersede_id = seed.as_deref().unwrap_or("nonexistent");
TicketBuilder::new(&store, target_ws)
.title("New")
.prereqs(&prereqs)
.supersede(supersede_id)
.await
.unwrap_err()
}
};
assert!(
err.to_string().contains(expected_error),
"Case '{op:?}/{scenario:?}': expected error containing \
'{expected_error}', got: {err}"
);
}
}
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,
None,
)
.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,
None,
)
.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,
None,
)
.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,
None,
)
.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", &ws);
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_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", &ws);
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_succeed 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 label = if should_succeed { "commit" } else { "rollback" };
let result: anyhow::Result<()> =
crate::turso::with_tx(&store.conn, &id, "test_triple_write", async |tx| {
BoardStore::set_commit_info_tx(
tx,
&id,
"abcdef0123456789abcdef0123456789abcd0123",
10,
5,
)
.await?;
BoardStore::add_comment_tx(
tx,
&id,
crate::role::SYSTEM_ROLE,
"triple write comment",
)
.await?;
BoardStore::transition_to_tx(
tx,
&id,
Some(TicketPhase::QaPassed),
TicketPhase::Done,
None,
)
.await?;
if should_succeed {
Ok(())
} else {
Err(anyhow::anyhow!("simulated failure for rollback test"))
}
})
.await;
if !should_succeed {
assert!(result.is_err(), "({label}) expected transaction failure");
}
let ticket = crate::util::test::expect_ticket(&store, &id).await;
let comments = store.get_comments(&id).await.expect("get comments");
if should_succeed {
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_no_cancel(&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_agent_id(&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 agent ID",
case.name
);
}
}
}
#[tokio::test]
async fn test_claim_sanitation_serialization() {
for same_workspace in [true, false] {
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 second_ws = if same_workspace { &ws_a } else { &ws_b };
let first_id = make_ticket(&store, &ws_a, "First", TicketPhase::QaPassed).await;
let second_id = make_ticket(&store, second_ws, "Second", TicketPhase::QaPassed).await;
let first_key =
crate::session::ticket_agent_id(&first_id, crate::Role::Sanitation.as_str());
let second_key =
crate::session::ticket_agent_id(&second_id, crate::Role::Sanitation.as_str());
assert!(
store
.claim_sanitation(&first_id, &first_key)
.await
.expect("first claim"),
"first claim should succeed"
);
let second_claimed = store
.claim_sanitation(&second_id, &second_key)
.await
.expect("second claim");
if same_workspace {
assert!(
!second_claimed,
"second claim should be blocked while first ticket is in the sanitation pipeline"
);
store
.transition_to(&first_id, None, TicketPhase::Done, None)
.await
.expect("transition first to Done");
assert!(
store
.claim_sanitation(&second_id, &second_key)
.await
.expect("second claim retry"),
"second claim should succeed after pipeline clears"
);
} else {
assert!(
second_claimed,
"claim in another workspace should succeed independently"
);
let a = expect_ticket(&store, &first_id).await;
let b = expect_ticket(&store, &second_id).await;
assert_eq!(a.phase, TicketPhase::InSanitation);
assert_eq!(b.phase, TicketPhase::InSanitation);
}
}
}
#[tokio::test]
async fn test_set_assigned_to_none() {
let (store, _tmp, id) = setup().await;
store
.set_assigned_to_no_cancel(&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_no_cancel(&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_no_cancel(&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_no_cancel("nonexistent", None).await;
assert!(
result.is_err(),
"set_assigned_to(None) on nonexistent ticket should fail"
);
}
#[expect(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!(
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,
Ticket {
id: id.clone(),
title: "Roundtrip Title".into(),
description: "Roundtrip description".into(),
phase: TicketPhase::Backlog,
assigned_to: None,
workspace_name: "test_workspace".into(),
created_at: fresh.created_at.clone(),
updated_at: fresh.updated_at.clone(),
comments: vec![],
prerequisites: vec![],
supersedes: None,
superseded_by: None,
commit_hash: None,
lines_added: None,
lines_removed: None,
reporter: "test_reporter".into(),
is_archived: false,
pipeline_reservation: false,
priority: 1,
reviewed_head: None,
reviewed_tree: None,
done_at: None,
bounce_count: 0,
},
);
store
.set_assigned_to_no_cancel(&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();
store
.set_reviewed_base(&id, Some("reviewed-head-hash"), Some("reviewed-tree-hash"))
.await
.expect("set_reviewed_base");
let ticket = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists");
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_eq!(
ticket,
Ticket {
id: id.clone(),
title: "Roundtrip Title".into(),
description: "Roundtrip description".into(),
phase: TicketPhase::Backlog,
assigned_to: Some("test_assignee".into()),
workspace_name: "test_workspace".into(),
created_at: ticket.created_at.clone(),
updated_at: ticket.updated_at.clone(),
comments: vec![],
prerequisites: vec![],
supersedes: None,
superseded_by: None,
commit_hash: Some("abcdef0123456789abcdef0123456789abcd0123".into()),
lines_added: Some(42),
lines_removed: Some(7),
reporter: "test_reporter".into(),
is_archived: false,
pipeline_reservation: false,
priority: 1,
reviewed_head: Some("reviewed-head-hash".into()),
reviewed_tree: Some("reviewed-tree-hash".into()),
done_at: None,
bounce_count: 0,
},
);
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.created_at.contains('T'),
"archived created_at should be RFC 3339: {}",
archived.created_at,
);
assert!(
archived.updated_at.contains('T'),
"archived updated_at should be RFC 3339: {}",
archived.updated_at,
);
assert_eq!(
archived,
Ticket {
id,
title: "Roundtrip Title".into(),
description: "Roundtrip description".into(),
phase: TicketPhase::Backlog,
assigned_to: None,
workspace_name: "test_workspace".into(),
created_at: archived.created_at.clone(),
updated_at: archived.updated_at.clone(),
comments: vec![],
prerequisites: vec![],
supersedes: None,
superseded_by: None,
commit_hash: Some("abcdef0123456789abcdef0123456789abcd0123".into()),
lines_added: Some(42),
lines_removed: Some(7),
reporter: "test_reporter".into(),
is_archived: true,
pipeline_reservation: false,
priority: 1,
reviewed_head: Some("reviewed-head-hash".into()),
reviewed_tree: Some("reviewed-tree-hash".into()),
done_at: None,
bounce_count: 0,
},
);
}
#[tokio::test]
async fn test_done_at_transition_semantics() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws_named("/test_ws", "test_workspace");
let id = TicketBuilder::new(&store, &ws)
.title("Done timestamp")
.create()
.await
.expect("create_ticket");
store
.transition_to(&id, None, TicketPhase::Done, None)
.await
.expect("transition to done");
let done = store.get_ticket(&id).await.expect("get").expect("ticket");
let first_done_at = done.done_at.expect("done_at set on completion");
assert!(
done.created_at < first_done_at,
"done_at should be later than creation"
);
store
.add_comment(&id, "manager", "nice work")
.await
.expect("add_comment");
let commented = store.get_ticket(&id).await.expect("get").expect("ticket");
assert_eq!(commented.done_at.as_deref(), Some(first_done_at.as_str()));
assert!(
commented.updated_at > first_done_at,
"comment should bump updated_at but not done_at"
);
store
.transition_to(&id, Some(TicketPhase::Done), TicketPhase::Backlog, None)
.await
.expect("reopen");
let reopened = store.get_ticket(&id).await.expect("get").expect("ticket");
assert_eq!(reopened.done_at, None, "done_at cleared when leaving Done");
store
.transition_to(&id, Some(TicketPhase::Backlog), TicketPhase::Done, None)
.await
.expect("re-complete");
let redone = store.get_ticket(&id).await.expect("get").expect("ticket");
assert!(
redone.done_at.as_deref().unwrap() > first_done_at.as_str(),
"re-completion re-stamps done_at with the new moment"
);
}
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
}
async fn create_active_ticket(
store: &super::BoardStore,
title: &str,
workspace_name: &str,
) -> String {
let ws = test_ws(workspace_name);
make_ticket(store, &ws, title, crate::board::TicketPhase::Backlog).await
}
#[tokio::test]
async fn test_search_by_fts_finds_matching_title() {
let (store, _tmp) = open_test_store().await;
let archived = create_archived_ticket(&store, "Fix network timeout bug", "ws1").await;
let active = create_active_ticket(&store, "Fix network timeout bug", "ws_active").await;
let archived_results = store
.search_archived_by_fts("network timeout", 10, "ws1")
.await
.expect("archived FTS search");
assert!(
archived_results.iter().any(|(id, _)| id == &archived),
"archived search should find the archived ticket"
);
let active_results = store
.search_by_fts("network timeout", 10, Some("ws_active"))
.await
.expect("FTS search");
assert!(
active_results.iter().any(|t| t.id == active),
"search should find the active ticket"
);
}
#[tokio::test]
async fn test_search_by_fts_includes_both_archive_states() {
let (store, _tmp) = open_test_store().await;
let archived = create_archived_ticket(&store, "still searching", "ws2").await;
let active = create_active_ticket(&store, "still active", "ws2").await;
let results = store
.search_by_fts("still", 10, Some("ws2"))
.await
.expect("general FTS search");
assert!(
results.iter().any(|t| t.id == archived),
"archived ticket must appear in general search results"
);
assert!(
results.iter().any(|t| t.id == active),
"active ticket must appear in general search results"
);
let archived_results = store
.search_archived_by_fts("active", 10, "ws2")
.await
.expect("archived FTS search");
assert!(
archived_results.is_empty(),
"non-archived ticket must not appear in archived search"
);
}
#[tokio::test]
async fn test_search_by_fts_sanitize_short_circuit() {
let (store, _tmp) = open_test_store().await;
for query in ["!@#$%", ""] {
let archived = store
.search_archived_by_fts(query, 10, "ws")
.await
.expect("archived FTS search");
assert!(
archived.is_empty(),
"query {query:?} yields no archived results"
);
let results = store
.search_by_fts(query, 10, Some("ws"))
.await
.expect("FTS search");
assert!(
results.is_empty(),
"query {query:?} yields no search results"
);
}
}
#[tokio::test]
async fn test_search_by_fts_scoped_to_workspace() {
let (store, _tmp) = open_test_store().await;
create_active_ticket(&store, "Fix network timeout bug", "ws_scope_a").await;
create_active_ticket(&store, "Database connection pool error", "ws_scope_b").await;
let results = store
.search_by_fts("network timeout", 10, Some("ws_scope_a"))
.await
.expect("FTS search scoped to ws_scope_a");
assert_eq!(results.len(), 1, "should find only ws_scope_a ticket");
assert_eq!(
results[0].workspace_name, "ws_scope_a",
"ticket belongs to ws_scope_a"
);
}
#[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("Priority: P1"),
"should contain priority label (default 1)"
);
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("ws")
.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("ws")
.await
.expect("list");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].0, id);
assert_eq!(candidates[0].1, vec![1.0, 2.0]);
}
#[tokio::test]
async fn test_route_comment_to_agents_no_assignment() {
crate::util::test::init_management_test_stores().await;
let store = crate::board::store();
let ws = crate::workspace::test_ws("/tmp/test_route_comment_no_assign");
let ticket_id = crate::util::test::make_ticket(
store,
&ws,
"no-assign-test",
crate::board::TicketPhase::Backlog,
)
.await;
store
.add_comment(&ticket_id, "manager", "No one should get this")
.await
.expect("add_comment should succeed");
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn test_route_comment_to_agents_delivers_with_commenter_role() {
crate::util::test::init_management_test_stores().await;
let store = crate::board::store();
for (i, (commenter, content, expected_role)) in [
("manager", "Hello from test", crate::Role::Manager),
("engineer", "Code review feedback", crate::Role::Engineer),
]
.into_iter()
.enumerate()
{
let ws = crate::workspace::test_ws(format!("/tmp/test_route_comment_{i}"));
let ticket_id = crate::util::test::make_ticket(
store,
&ws,
&format!("route-comment-test-{i}"),
crate::board::TicketPhase::InDevelopment,
)
.await;
let agent_id = format!("_test_route_comment_agent_{i}");
store
.set_assigned_to_no_cancel(&ticket_id, Some(&agent_id))
.await
.expect("set assigned_to");
let mut rx = crate::message_router::register_agent(&agent_id);
store
.add_comment(&ticket_id, commenter, content)
.await
.expect("add_comment should succeed");
let received = rx.try_recv().expect("should receive the routed comment");
assert_eq!(received.content, content);
assert_eq!(received.kind, crate::message_router::JobKind::TicketComment);
assert_eq!(received.user_name, commenter);
assert_eq!(
received.role, expected_role,
"role should be the commenter's role ({commenter})",
);
assert!(
rx.try_recv().is_err(),
"should not have additional messages"
);
crate::message_router::unregister_agent(&agent_id);
}
}
#[tokio::test]
async fn test_bounce_back_to_dev_transitions_and_increments_counter() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws("/tmp/test_bounce_back_to_dev");
let id = make_ticket(&store, &ws, "Redo Dev", TicketPhase::Reviewed).await;
assert!(
store
.bounce_back_to_dev(&id)
.await
.expect("bounce-back succeeds"),
"bounce-back from Reviewed must apply"
);
let ticket = expect_ticket(&store, &id).await;
assert_eq!(ticket.phase, TicketPhase::ReadyForDevelopment);
assert_eq!(ticket.bounce_count, 1, "manual bounce must count");
store
.transition_to(&id, None, TicketPhase::Reviewed, None)
.await
.expect("move back to Reviewed for a second round");
assert!(
store
.bounce_back_to_dev(&id)
.await
.expect("second bounce-back succeeds"),
"second bounce-back from Reviewed must apply"
);
let ticket = expect_ticket(&store, &id).await;
assert_eq!(ticket.bounce_count, 2);
store
.transition_to(&id, None, TicketPhase::InQa, None)
.await
.expect("move to InQa");
let outcome = store
.bounce_back_to_dev(&id)
.await
.expect("guard-missed bounce-back must not error");
assert!(
!outcome,
"bounce-back from a non-Reviewed phase must report the guard miss"
);
let ticket = expect_ticket(&store, &id).await;
assert_eq!(
ticket.phase,
TicketPhase::InQa,
"guard-missed bounce-back must leave the ticket untouched"
);
assert_eq!(
ticket.bounce_count, 2,
"guard-missed bounce-back must not bump the bounce counter"
);
}