test: update ticket schema expectations
This commit is contained in:
parent
191a875f5a
commit
591db3ff72
|
|
@ -2827,20 +2827,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_item_frontmatter_and_optional_fields() {
|
fn parses_item_frontmatter_and_optional_fields() {
|
||||||
let item = r#"---
|
let item = r#"---
|
||||||
id: 20260605-000000-example
|
|
||||||
slug: example
|
|
||||||
title: Example
|
title: Example
|
||||||
status: open
|
state: ready
|
||||||
kind: task
|
|
||||||
priority: P1
|
priority: P1
|
||||||
labels: [ticket, backend]
|
|
||||||
created_at: 2026-06-05T00:00:00Z
|
created_at: 2026-06-05T00:00:00Z
|
||||||
updated_at: 2026-06-05T00:00:00Z
|
updated_at: 2026-06-05T00:00:00Z
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: implementation-ready
|
readiness: implementation-ready
|
||||||
risk_flags: [low, local]
|
risk_flags: [low, local]
|
||||||
action_required: none
|
action_required: none
|
||||||
workflow_state: ready
|
|
||||||
attention_required: none
|
attention_required: none
|
||||||
queued_by: workspace-panel
|
queued_by: workspace-panel
|
||||||
queued_at: 2026-06-05T00:01:00Z
|
queued_at: 2026-06-05T00:01:00Z
|
||||||
|
|
@ -2850,8 +2845,9 @@ queued_at: 2026-06-05T00:01:00Z
|
||||||
"#;
|
"#;
|
||||||
let parsed = parse_item(item).unwrap();
|
let parsed = parse_item(item).unwrap();
|
||||||
let meta = ticket_meta(parsed.frontmatter, "20260609-000000-001".to_string());
|
let meta = ticket_meta(parsed.frontmatter, "20260609-000000-001".to_string());
|
||||||
assert_eq!(meta.id, "20260605-000000-example");
|
assert_eq!(meta.id, "20260609-000000-001");
|
||||||
assert_eq!(meta.labels, vec!["ticket", "backend"]);
|
assert_eq!(meta.slug, "20260609-000000-001");
|
||||||
|
assert!(meta.labels.is_empty());
|
||||||
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
|
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
|
||||||
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
||||||
assert_eq!(meta.action_required.as_deref(), Some("none"));
|
assert_eq!(meta.action_required.as_deref(), Some("none"));
|
||||||
|
|
@ -2865,20 +2861,17 @@ queued_at: 2026-06-05T00:01:00Z
|
||||||
#[test]
|
#[test]
|
||||||
fn yaml_frontmatter_preserves_typed_nulls_lists_and_quoted_strings() {
|
fn yaml_frontmatter_preserves_typed_nulls_lists_and_quoted_strings() {
|
||||||
let frontmatter = parse_ticket_frontmatter(
|
let frontmatter = parse_ticket_frontmatter(
|
||||||
r#"labels:
|
r#"risk_flags: [low, local]
|
||||||
- ticket
|
|
||||||
- backend
|
|
||||||
risk_flags: [low, local]
|
|
||||||
assignee: ~
|
assignee: ~
|
||||||
attention_required: null
|
attention_required: null
|
||||||
action_required: "null"
|
action_required: "null"
|
||||||
readiness: "~"
|
readiness: "~"
|
||||||
workflow_state: planning
|
state: planning
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let meta = ticket_meta(frontmatter, "20260609-000000-001".to_string());
|
let meta = ticket_meta(frontmatter, "20260609-000000-001".to_string());
|
||||||
assert_eq!(meta.labels, vec!["ticket", "backend"]);
|
assert!(meta.labels.is_empty());
|
||||||
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
||||||
assert_eq!(meta.assignee, None);
|
assert_eq!(meta.assignee, None);
|
||||||
assert_eq!(meta.attention_required, None);
|
assert_eq!(meta.attention_required, None);
|
||||||
|
|
@ -2896,17 +2889,11 @@ workflow_state: planning
|
||||||
"{labels_error}"
|
"{labels_error}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let workflow_error = parse_ticket_frontmatter("workflow_state: almost").unwrap_err();
|
let state_error = parse_ticket_frontmatter("state: almost").unwrap_err();
|
||||||
assert!(
|
assert!(state_error.contains("invalid state"), "{state_error}");
|
||||||
workflow_error.contains("invalid workflow_state"),
|
|
||||||
"{workflow_error}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let intake_error = parse_ticket_frontmatter("workflow_state: intake").unwrap_err();
|
let intake_error = parse_ticket_frontmatter("state: intake").unwrap_err();
|
||||||
assert!(
|
assert!(intake_error.contains("invalid state"), "{intake_error}");
|
||||||
intake_error.contains("invalid workflow_state"),
|
|
||||||
"{intake_error}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2922,12 +2909,31 @@ workflow_state: planning
|
||||||
let mut input = NewTicket::new("Example Ticket");
|
let mut input = NewTicket::new("Example Ticket");
|
||||||
input.labels = vec!["ticket".into(), "backend".into()];
|
input.labels = vec!["ticket".into(), "backend".into()];
|
||||||
let ticket = backend.create(input).unwrap();
|
let ticket = backend.create(input).unwrap();
|
||||||
let dir = tmp.path().join("tickets/open").join(&ticket.id);
|
let dir = tmp.path().join("tickets").join(&ticket.id);
|
||||||
assert!(dir.join("item.md").exists());
|
assert!(dir.join("item.md").exists());
|
||||||
assert!(dir.join("thread.md").exists());
|
assert!(dir.join("thread.md").exists());
|
||||||
assert!(dir.join("artifacts/.gitkeep").exists());
|
assert!(dir.join("artifacts/.gitkeep").exists());
|
||||||
assert_eq!(ticket.slug, "example-ticket");
|
assert!(!ticket.id.contains("example"));
|
||||||
|
assert_eq!(ticket.slug, ticket.id);
|
||||||
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
||||||
|
assert!(
|
||||||
|
item.contains("state: planning")
|
||||||
|
|| item.contains("state: \"planning\"")
|
||||||
|
|| item.contains("state: 'planning'")
|
||||||
|
);
|
||||||
|
for obsolete in [
|
||||||
|
"id:",
|
||||||
|
"slug:",
|
||||||
|
"status:",
|
||||||
|
"workflow_state:",
|
||||||
|
"kind:",
|
||||||
|
"labels:",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!item.contains(obsolete),
|
||||||
|
"obsolete field {obsolete} in {item}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(!item.contains("legacy_ticket:"));
|
assert!(!item.contains("legacy_ticket:"));
|
||||||
assert!(!item.contains("needs_preflight:"));
|
assert!(!item.contains("needs_preflight:"));
|
||||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
||||||
|
|
@ -2944,10 +2950,7 @@ workflow_state: planning
|
||||||
.with_record_language(Some("Japanese"));
|
.with_record_language(Some("Japanese"));
|
||||||
|
|
||||||
let created = backend.create(NewTicket::new("日本語レコード")).unwrap();
|
let created = backend.create(NewTicket::new("日本語レコード")).unwrap();
|
||||||
let dir = backend
|
let dir = backend.root().join(created.id.as_str());
|
||||||
.root()
|
|
||||||
.join(TicketStatus::Open.as_str())
|
|
||||||
.join(created.id.as_str());
|
|
||||||
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
||||||
let thread = fs::read_to_string(dir.join("thread.md")).unwrap();
|
let thread = fs::read_to_string(dir.join("thread.md")).unwrap();
|
||||||
|
|
||||||
|
|
@ -2962,8 +2965,6 @@ workflow_state: planning
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let mut input = NewTicket::new("123");
|
let mut input = NewTicket::new("123");
|
||||||
input.slug = Some("numeric-looking-strings".to_string());
|
|
||||||
input.labels = vec!["123".into(), "01".into()];
|
|
||||||
input.risk_flags = vec!["1".into(), "42".into()];
|
input.risk_flags = vec!["1".into(), "42".into()];
|
||||||
input.assignee = Some("42".into());
|
input.assignee = Some("42".into());
|
||||||
input.attention_required = Some("0".into());
|
input.attention_required = Some("0".into());
|
||||||
|
|
@ -2972,21 +2973,16 @@ workflow_state: planning
|
||||||
|
|
||||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
||||||
assert_eq!(record.meta.title, "123");
|
assert_eq!(record.meta.title, "123");
|
||||||
assert_eq!(record.meta.labels, vec!["123", "01"]);
|
assert!(record.meta.labels.is_empty());
|
||||||
assert_eq!(record.meta.risk_flags, vec!["1", "42"]);
|
assert_eq!(record.meta.risk_flags, vec!["1", "42"]);
|
||||||
assert_eq!(record.meta.assignee.as_deref(), Some("42"));
|
assert_eq!(record.meta.assignee.as_deref(), Some("42"));
|
||||||
assert_eq!(record.meta.attention_required.as_deref(), Some("0"));
|
assert_eq!(record.meta.attention_required.as_deref(), Some("0"));
|
||||||
assert_eq!(record.meta.action_required.as_deref(), Some("true"));
|
assert_eq!(record.meta.action_required.as_deref(), Some("true"));
|
||||||
|
|
||||||
let item = fs::read_to_string(
|
let item = fs::read_to_string(tmp.path().join("tickets").join(&ticket.id).join("item.md"))
|
||||||
tmp.path()
|
|
||||||
.join("tickets/open")
|
|
||||||
.join(&ticket.id)
|
|
||||||
.join("item.md"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(item.contains("title: '123'"), "{item}");
|
assert!(item.contains("title: '123'"), "{item}");
|
||||||
assert!(item.contains("labels: ['123', '01']"), "{item}");
|
assert!(!item.contains("labels:"), "{item}");
|
||||||
assert!(item.contains("risk_flags: ['1', '42']"), "{item}");
|
assert!(item.contains("risk_flags: ['1', '42']"), "{item}");
|
||||||
assert!(item.contains("assignee: '42'"), "{item}");
|
assert!(item.contains("assignee: '42'"), "{item}");
|
||||||
assert!(item.contains("attention_required: '0'"), "{item}");
|
assert!(item.contains("attention_required: '0'"), "{item}");
|
||||||
|
|
@ -3003,7 +2999,7 @@ workflow_state: planning
|
||||||
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
||||||
backend
|
backend
|
||||||
.add_event(
|
.add_event(
|
||||||
TicketIdOrSlug::Slug(ticket.slug.clone()),
|
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -3013,22 +3009,27 @@ workflow_state: planning
|
||||||
TicketReview::approve("Looks good."),
|
TicketReview::approve("Looks good."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
||||||
|
summary.author = Some("test".to_string());
|
||||||
|
let mut change = TicketStateChange::new(
|
||||||
|
"planning",
|
||||||
|
"ready",
|
||||||
|
"ready_for_queue",
|
||||||
|
MarkdownText::new("Ready for queue."),
|
||||||
|
);
|
||||||
|
change.author = Some("test".to_string());
|
||||||
backend
|
backend
|
||||||
.set_status(TicketIdOrSlug::Id(ticket.id.clone()), TicketStatus::Pending)
|
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let pending_item = tmp
|
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
||||||
.path()
|
assert!(current_item.exists());
|
||||||
.join("tickets/pending")
|
|
||||||
.join(&ticket.id)
|
|
||||||
.join("item.md");
|
|
||||||
assert!(pending_item.exists());
|
|
||||||
backend
|
backend
|
||||||
.close(
|
.close(
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||||
MarkdownText::new("Done.\n"),
|
MarkdownText::new("Done.\n"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let closed_dir = tmp.path().join("tickets/closed").join(&ticket.id);
|
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
||||||
assert!(closed_dir.join("resolution.md").exists());
|
assert!(closed_dir.join("resolution.md").exists());
|
||||||
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
||||||
assert!(thread.contains("<!-- event: review"));
|
assert!(thread.contains("<!-- event: review"));
|
||||||
|
|
@ -3047,7 +3048,7 @@ workflow_state: planning
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let thread_path = tmp
|
let thread_path = tmp
|
||||||
.path()
|
.path()
|
||||||
.join("tickets/open")
|
.join("tickets")
|
||||||
.join(&ticket.id)
|
.join(&ticket.id)
|
||||||
.join("thread.md");
|
.join("thread.md");
|
||||||
let original = fs::read_to_string(&thread_path).unwrap();
|
let original = fs::read_to_string(&thread_path).unwrap();
|
||||||
|
|
@ -3084,16 +3085,17 @@ workflow_state: planning
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let mut input = NewTicket::new("Invalid Author Ticket");
|
let mut input = NewTicket::new("Invalid Author Ticket");
|
||||||
input.slug = Some("invalid-author-ticket".into());
|
|
||||||
input.author = Some("bad-->author".into());
|
input.author = Some("bad-->author".into());
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
backend.create(input),
|
backend.create(input),
|
||||||
Err(TicketError::Conflict(_))
|
Err(TicketError::Conflict(_))
|
||||||
));
|
));
|
||||||
let open_dir = tmp.path().join("tickets/open");
|
let ticket_dirs = fs::read_dir(tmp.path().join("tickets"))
|
||||||
let entries = fs::read_dir(open_dir).unwrap().count();
|
.unwrap()
|
||||||
assert_eq!(entries, 0);
|
.filter(|entry| entry.as_ref().is_ok_and(|entry| entry.path().is_dir()))
|
||||||
|
.count();
|
||||||
|
assert_eq!(ticket_dirs, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3142,7 +3144,7 @@ workflow_state: planning
|
||||||
);
|
);
|
||||||
let thread = fs::read_to_string(
|
let thread = fs::read_to_string(
|
||||||
tmp.path()
|
tmp.path()
|
||||||
.join("tickets/open")
|
.join("tickets")
|
||||||
.join(&ticket.id)
|
.join(&ticket.id)
|
||||||
.join("thread.md"),
|
.join("thread.md"),
|
||||||
)
|
)
|
||||||
|
|
@ -3161,11 +3163,7 @@ workflow_state: planning
|
||||||
let ticket = backend
|
let ticket = backend
|
||||||
.create(NewTicket::new("State Field Ticket"))
|
.create(NewTicket::new("State Field Ticket"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let item = tmp
|
let item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
||||||
.path()
|
|
||||||
.join("tickets/open")
|
|
||||||
.join(&ticket.id)
|
|
||||||
.join("item.md");
|
|
||||||
backend
|
backend
|
||||||
.set_frontmatter_fields(&item, &[("readiness", "requirements-sync")])
|
.set_frontmatter_fields(&item, &[("readiness", "requirements-sync")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -3205,11 +3203,11 @@ workflow_state: planning
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_state_defaults_and_queue_transition_round_trip() {
|
fn state_defaults_and_queue_transition_round_trip() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let missing_meta = ticket_meta(
|
let missing_meta = ticket_meta(
|
||||||
parse_ticket_frontmatter("state: planning").expect("missing state parses"),
|
parse_ticket_frontmatter("title: Missing State").expect("missing state parses"),
|
||||||
"20260609-000000-001".to_string(),
|
"20260609-000000-001".to_string(),
|
||||||
);
|
);
|
||||||
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
|
|
@ -3219,8 +3217,8 @@ workflow_state: planning
|
||||||
parse_ticket_frontmatter("state: closed").expect("closed state parses"),
|
parse_ticket_frontmatter("state: closed").expect("closed state parses"),
|
||||||
"20260609-000000-002".to_string(),
|
"20260609-000000-002".to_string(),
|
||||||
);
|
);
|
||||||
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Closed);
|
||||||
assert!(!closed_meta.workflow_state_explicit);
|
assert!(closed_meta.workflow_state_explicit);
|
||||||
|
|
||||||
let mut ready_input = NewTicket::new("Ready Workflow");
|
let mut ready_input = NewTicket::new("Ready Workflow");
|
||||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
|
@ -3239,7 +3237,7 @@ workflow_state: planning
|
||||||
.iter()
|
.iter()
|
||||||
.find(|event| event.kind == TicketEventKind::StateChanged)
|
.find(|event| event.kind == TicketEventKind::StateChanged)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(event.state_field.as_deref(), Some("workflow_state"));
|
assert_eq!(event.state_field.as_deref(), Some("state"));
|
||||||
assert_eq!(event.from.as_deref(), Some("ready"));
|
assert_eq!(event.from.as_deref(), Some("ready"));
|
||||||
assert_eq!(event.to.as_deref(), Some("queued"));
|
assert_eq!(event.to.as_deref(), Some("queued"));
|
||||||
assert_eq!(event.reason.as_deref(), Some("queued"));
|
assert_eq!(event.reason.as_deref(), Some("queued"));
|
||||||
|
|
@ -3267,7 +3265,7 @@ workflow_state: planning
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_state_cannot_be_changed_through_generic_state_field_api() {
|
fn state_cannot_be_changed_through_generic_field_api() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let ticket = backend
|
let ticket = backend
|
||||||
|
|
@ -3277,15 +3275,11 @@ workflow_state: planning
|
||||||
"planning",
|
"planning",
|
||||||
"done",
|
"done",
|
||||||
"bypass",
|
"bypass",
|
||||||
"Generic state field API must not mutate workflow_state.",
|
"Generic field API must not mutate state.",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
backend.set_state_field(
|
backend.set_state_field(TicketIdOrSlug::Id(ticket.id.clone()), "state", change),
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
|
||||||
"workflow_state",
|
|
||||||
change
|
|
||||||
),
|
|
||||||
Err(TicketError::Conflict(_))
|
Err(TicketError::Conflict(_))
|
||||||
));
|
));
|
||||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||||
|
|
@ -3316,14 +3310,14 @@ workflow_state: planning
|
||||||
);
|
);
|
||||||
assert!(record.events.iter().any(|event| {
|
assert!(record.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
&& event.state_field.as_deref() == Some("workflow_state")
|
&& event.state_field.as_deref() == Some("state")
|
||||||
&& event.from.as_deref() == Some("planning")
|
&& event.from.as_deref() == Some("planning")
|
||||||
&& event.to.as_deref() == Some("ready")
|
&& event.to.as_deref() == Some("ready")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_sets_workflow_state_done() {
|
fn close_sets_state_closed() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let mut input = NewTicket::new("Close Workflow");
|
let mut input = NewTicket::new("Close Workflow");
|
||||||
|
|
@ -3338,27 +3332,25 @@ workflow_state: planning
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||||
assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed);
|
assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed);
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Closed);
|
||||||
assert!(record.events.iter().any(|event| {
|
assert!(record.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
&& event.state_field.as_deref() == Some("workflow_state")
|
&& event.state_field.as_deref() == Some("state")
|
||||||
&& event.to.as_deref() == Some("done")
|
&& event.to.as_deref() == Some("closed")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn doctor_reports_invalid_workflow_state() {
|
fn doctor_reports_invalid_state() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path().join("tickets");
|
let root = tmp.path().join("tickets");
|
||||||
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
|
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/item.md"),
|
root.join("20260609-000000-001/item.md"),
|
||||||
"---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nworkflow_state: almost\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\n---\n",
|
"---\ntitle: Bad\nstate: almost\ncreated_at: x\nupdated_at: x\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(root.join("open/bad/thread.md"), "").unwrap();
|
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
|
||||||
fs::create_dir_all(root.join("pending")).unwrap();
|
|
||||||
fs::create_dir_all(root.join("closed")).unwrap();
|
|
||||||
|
|
||||||
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
||||||
let messages = report
|
let messages = report
|
||||||
|
|
@ -3368,26 +3360,24 @@ workflow_state: planning
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
assert!(!report.is_ok());
|
assert!(!report.is_ok());
|
||||||
assert!(messages.contains("invalid workflow_state"));
|
assert!(messages.contains("invalid state"), "{messages}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn doctor_validates_typed_thread_event_attributes() {
|
fn doctor_validates_typed_thread_event_attributes() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path().join("tickets");
|
let root = tmp.path().join("tickets");
|
||||||
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
|
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/item.md"),
|
root.join("20260609-000000-001/item.md"),
|
||||||
"---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\n---\n",
|
"---\ntitle: Bad\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/thread.md"),
|
root.join("20260609-000000-001/thread.md"),
|
||||||
"<!-- event: state_changed author: bot at: now from: queued -->\n\n## State changed\n\n---\n\n<!-- event: intake_summary author: bot at: now -->\n\n## Intake summary\n\n---\n",
|
"<!-- event: state_changed author: bot at: now from: queued -->\n\n## State changed\n\n---\n\n<!-- event: intake_summary author: bot at: now -->\n\n## Intake summary\n\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::create_dir_all(root.join("pending")).unwrap();
|
|
||||||
fs::create_dir_all(root.join("closed")).unwrap();
|
|
||||||
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
||||||
let messages = report
|
let messages = report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
|
|
@ -3405,25 +3395,24 @@ workflow_state: planning
|
||||||
fn doctor_reports_core_consistency_errors() {
|
fn doctor_reports_core_consistency_errors() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path().join("tickets");
|
let root = tmp.path().join("tickets");
|
||||||
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
|
fs::create_dir_all(root.join("open/legacy/artifacts")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/item.md"),
|
root.join("open/legacy/item.md"),
|
||||||
"---\nid: other\nslug: dup\ntitle: Bad\nstatus: pending\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\n---\n",
|
"---\ntitle: Legacy\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(root.join("open/legacy/thread.md"), "").unwrap();
|
||||||
|
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
root.join("20260609-000000-001/item.md"),
|
||||||
|
"---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\ncreated_at: x\nupdated_at: x\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/thread.md"),
|
root.join("20260609-000000-001/thread.md"),
|
||||||
"<!-- event: review author: a at: now -->\n",
|
"<!-- event: review author: a at: now -->\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::create_dir_all(root.join("pending/other/artifacts")).unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("pending/other/item.md"),
|
|
||||||
"---\nid: other\nslug: dup\ntitle: Dup\nstatus: pending\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(root.join("pending/other/thread.md"), "").unwrap();
|
|
||||||
fs::create_dir_all(root.join("closed")).unwrap();
|
|
||||||
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
||||||
let messages = report
|
let messages = report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
|
|
@ -3432,10 +3421,13 @@ workflow_state: planning
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
assert!(!report.is_ok());
|
assert!(!report.is_ok());
|
||||||
assert!(messages.contains("directory id mismatch"));
|
assert!(messages.contains("legacy ticket bucket remains"));
|
||||||
assert!(messages.contains("status mismatch"));
|
assert!(messages.contains("obsolete current frontmatter field 'id'"));
|
||||||
assert!(messages.contains("duplicate id: other"));
|
assert!(messages.contains("obsolete current frontmatter field 'slug'"));
|
||||||
assert!(messages.contains("duplicate slug: dup"));
|
assert!(messages.contains("obsolete current frontmatter field 'status'"));
|
||||||
|
assert!(messages.contains("obsolete current frontmatter field 'workflow_state'"));
|
||||||
|
assert!(messages.contains("obsolete current frontmatter field 'kind'"));
|
||||||
|
assert!(messages.contains("obsolete current frontmatter field 'labels'"));
|
||||||
assert!(messages.contains("review event missing valid status"));
|
assert!(messages.contains("review event missing valid status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3462,17 +3454,15 @@ workflow_state: planning
|
||||||
fn rejects_unsafe_components_for_status_moves() {
|
fn rejects_unsafe_components_for_status_moves() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path().join("tickets");
|
let root = tmp.path().join("tickets");
|
||||||
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
|
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("open/bad/item.md"),
|
root.join("20260609-000000-001/item.md"),
|
||||||
"---\nid: ../bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\n---\n",
|
"---\ntitle: Safe\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(root.join("open/bad/thread.md"), "").unwrap();
|
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
|
||||||
fs::create_dir_all(root.join("pending")).unwrap();
|
|
||||||
fs::create_dir_all(root.join("closed")).unwrap();
|
|
||||||
let err = LocalTicketBackend::new(&root)
|
let err = LocalTicketBackend::new(&root)
|
||||||
.set_status(TicketIdOrSlug::Slug("bad".into()), TicketStatus::Pending)
|
.set_status(TicketIdOrSlug::Id("../bad".into()), TicketStatus::Pending)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
|
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
|
||||||
}
|
}
|
||||||
|
|
@ -3489,7 +3479,7 @@ workflow_state: planning
|
||||||
TicketIdOrSlug::Id(first.id.clone()),
|
TicketIdOrSlug::Id(first.id.clone()),
|
||||||
NewOrchestrationPlanRecord {
|
NewOrchestrationPlanRecord {
|
||||||
kind: OrchestrationPlanKind::Before,
|
kind: OrchestrationPlanKind::Before,
|
||||||
related_ticket: Some(second.slug.clone()),
|
related_ticket: Some(second.id.clone()),
|
||||||
note: Some(
|
note: Some(
|
||||||
"First must land before second because both touch routing.".to_string(),
|
"First must land before second because both touch routing.".to_string(),
|
||||||
),
|
),
|
||||||
|
|
@ -3503,7 +3493,7 @@ workflow_state: planning
|
||||||
|
|
||||||
backend
|
backend
|
||||||
.add_orchestration_plan_record(
|
.add_orchestration_plan_record(
|
||||||
TicketIdOrSlug::Slug(first.slug.clone()),
|
TicketIdOrSlug::Id(first.id.clone()),
|
||||||
NewOrchestrationPlanRecord {
|
NewOrchestrationPlanRecord {
|
||||||
kind: OrchestrationPlanKind::AcceptedPlan,
|
kind: OrchestrationPlanKind::AcceptedPlan,
|
||||||
related_ticket: None,
|
related_ticket: None,
|
||||||
|
|
@ -3523,7 +3513,7 @@ workflow_state: planning
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let ticket_records = backend
|
let ticket_records = backend
|
||||||
.query_orchestration_plan_records(Some(TicketIdOrSlug::Query(first.slug.clone())), None)
|
.query_orchestration_plan_records(Some(TicketIdOrSlug::Query(first.id.clone())), None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ticket_records.len(), 2);
|
assert_eq!(ticket_records.len(), 2);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -3538,13 +3528,12 @@ workflow_state: planning
|
||||||
assert_eq!(before_records.len(), 1);
|
assert_eq!(before_records.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before_records[0].related_ticket.as_deref(),
|
before_records[0].related_ticket.as_deref(),
|
||||||
Some(second.slug.as_str())
|
Some(second.id.as_str())
|
||||||
);
|
);
|
||||||
|
|
||||||
let path = temp
|
let path = temp
|
||||||
.path()
|
.path()
|
||||||
.join("tickets")
|
.join("tickets")
|
||||||
.join("open")
|
|
||||||
.join(&first.id)
|
.join(&first.id)
|
||||||
.join("artifacts")
|
.join("artifacts")
|
||||||
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
||||||
|
|
@ -3579,7 +3568,6 @@ workflow_state: planning
|
||||||
let artifact = temp
|
let artifact = temp
|
||||||
.path()
|
.path()
|
||||||
.join("tickets")
|
.join("tickets")
|
||||||
.join("open")
|
|
||||||
.join(&ticket.id)
|
.join(&ticket.id)
|
||||||
.join("artifacts")
|
.join("artifacts")
|
||||||
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
||||||
|
|
|
||||||
|
|
@ -1197,8 +1197,6 @@ mod tests {
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"title": "Tool Created",
|
"title": "Tool Created",
|
||||||
"slug": "tool-created",
|
|
||||||
"labels": ["ticket", "tool"],
|
|
||||||
"body": "## Background\n\nCreated by tool.\n"
|
"body": "## Background\n\nCreated by tool.\n"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -1213,7 +1211,7 @@ mod tests {
|
||||||
assert!(!created_text.contains("needs_preflight"));
|
assert!(!created_text.contains("needs_preflight"));
|
||||||
|
|
||||||
let listed = list
|
let listed = list
|
||||||
.execute(&json!({ "state": "open", "label": "tool" }).to_string())
|
.execute(&json!({ "state": "planning" }).to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(listed.summary.contains("Listed 1 ticket"));
|
assert!(listed.summary.contains("Listed 1 ticket"));
|
||||||
|
|
@ -1226,7 +1224,7 @@ mod tests {
|
||||||
.execute(&json!({ "id": id, "event_limit": 10 }).to_string())
|
.execute(&json!({ "id": id, "event_limit": 10 }).to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(shown.summary.contains("tool-created"));
|
assert!(shown.summary.contains(&id));
|
||||||
let shown_content = shown.content.unwrap();
|
let shown_content = shown.content.unwrap();
|
||||||
assert!(shown_content.contains("Created by tool"));
|
assert!(shown_content.contains("Created by tool"));
|
||||||
assert!(!shown_content.contains("legacy_ticket"));
|
assert!(!shown_content.contains("legacy_ticket"));
|
||||||
|
|
@ -1243,14 +1241,13 @@ mod tests {
|
||||||
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||||
let comment = tool_by_name(backend.clone(), "TicketComment");
|
let comment = tool_by_name(backend.clone(), "TicketComment");
|
||||||
let review = tool_by_name(backend.clone(), "TicketReview");
|
let review = tool_by_name(backend.clone(), "TicketReview");
|
||||||
let state = tool_by_name(backend.clone(), "TicketStatus");
|
|
||||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||||
|
|
||||||
comment
|
comment
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.slug,
|
"ticket": created.id.clone(),
|
||||||
"role": "implementation_report",
|
"role": "implementation_report",
|
||||||
"body": "Implemented."
|
"body": "Implemented."
|
||||||
})
|
})
|
||||||
|
|
@ -1261,7 +1258,7 @@ mod tests {
|
||||||
review
|
review
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.id,
|
"ticket": created.id.clone(),
|
||||||
"result": "approve",
|
"result": "approve",
|
||||||
"body": "Looks good."
|
"body": "Looks good."
|
||||||
})
|
})
|
||||||
|
|
@ -1269,10 +1266,6 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
state
|
|
||||||
.execute(&json!({ "ticket": created.slug, "state": "pending" }).to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
close
|
close
|
||||||
.execute(
|
.execute(
|
||||||
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
||||||
|
|
@ -1283,7 +1276,7 @@ mod tests {
|
||||||
|
|
||||||
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
|
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
|
||||||
assert!(report.summary.contains("0 error(s)"));
|
assert!(report.summary.contains("0 error(s)"));
|
||||||
let closed = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
let closed = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert!(closed.resolution.is_some());
|
assert!(closed.resolution.is_some());
|
||||||
assert!(
|
assert!(
|
||||||
closed
|
closed
|
||||||
|
|
@ -1301,7 +1294,7 @@ mod tests {
|
||||||
closed
|
closed
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == TicketEventKind::StatusChanged)
|
.any(|event| event.kind == TicketEventKind::StateChanged)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1316,7 +1309,7 @@ mod tests {
|
||||||
intake_ready
|
intake_ready
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.slug,
|
"ticket": created.id.clone(),
|
||||||
"intake_summary": "Requirements accepted; implementation can be queued.",
|
"intake_summary": "Requirements accepted; implementation can be queued.",
|
||||||
"author": "intake-pod"
|
"author": "intake-pod"
|
||||||
})
|
})
|
||||||
|
|
@ -1330,7 +1323,7 @@ mod tests {
|
||||||
workflow
|
workflow
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.slug,
|
"ticket": created.id.clone(),
|
||||||
"from": "queued",
|
"from": "queued",
|
||||||
"to": "inprogress",
|
"to": "inprogress",
|
||||||
"reason": "orchestrator_started",
|
"reason": "orchestrator_started",
|
||||||
|
|
@ -1344,7 +1337,7 @@ mod tests {
|
||||||
workflow
|
workflow
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.slug,
|
"ticket": created.id.clone(),
|
||||||
"from": "inprogress",
|
"from": "inprogress",
|
||||||
"to": "done",
|
"to": "done",
|
||||||
"reason": "implementation_complete",
|
"reason": "implementation_complete",
|
||||||
|
|
@ -1356,7 +1349,7 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1408,7 +1401,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let ready_record = backend.show(TicketIdOrSlug::Query(ready.slug)).unwrap();
|
let ready_record = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ready_record.meta.workflow_state,
|
ready_record.meta.workflow_state,
|
||||||
TicketWorkflowState::Planning
|
TicketWorkflowState::Planning
|
||||||
|
|
@ -1437,7 +1430,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let queued_record = backend.show(TicketIdOrSlug::Query(queued.slug)).unwrap();
|
let queued_record = backend.show(TicketIdOrSlug::Id(queued.id)).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
queued_record.meta.workflow_state,
|
queued_record.meta.workflow_state,
|
||||||
TicketWorkflowState::Planning
|
TicketWorkflowState::Planning
|
||||||
|
|
@ -1462,7 +1455,7 @@ mod tests {
|
||||||
let error = workflow
|
let error = workflow
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.id,
|
"ticket": created.id.clone(),
|
||||||
"from": "queued",
|
"from": "queued",
|
||||||
"to": "inprogress",
|
"to": "inprogress",
|
||||||
"reason": "orchestrator_started",
|
"reason": "orchestrator_started",
|
||||||
|
|
@ -1474,7 +1467,7 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("state changed concurrently"));
|
assert!(error.to_string().contains("state changed concurrently"));
|
||||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
||||||
assert!(!record.events.iter().any(|event| {
|
assert!(!record.events.iter().any(|event| {
|
||||||
|
|
@ -1556,7 +1549,7 @@ mod tests {
|
||||||
let error = intake_ready
|
let error = intake_ready
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.id,
|
"ticket": created.id.clone(),
|
||||||
"intake_summary": "Should not rewrite ready ticket."
|
"intake_summary": "Should not rewrite ready ticket."
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -1565,7 +1558,7 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("state changed concurrently"));
|
assert!(error.to_string().contains("state changed concurrently"));
|
||||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||||
assert!(!record.events.iter().any(|event| {
|
assert!(!record.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
|
|
@ -1585,9 +1578,9 @@ mod tests {
|
||||||
let recorded = record
|
let recorded = record
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": first.slug,
|
"ticket": first.id.clone(),
|
||||||
"kind": "blocked_by",
|
"kind": "blocked_by",
|
||||||
"related_ticket": second.slug,
|
"related_ticket": second.id.clone(),
|
||||||
"note": "Wait for the second Ticket's API boundary decision.",
|
"note": "Wait for the second Ticket's API boundary decision.",
|
||||||
"author": "orchestrator"
|
"author": "orchestrator"
|
||||||
})
|
})
|
||||||
|
|
@ -1614,7 +1607,7 @@ mod tests {
|
||||||
let found_json: Value = serde_json::from_str(&found.content.unwrap()).unwrap();
|
let found_json: Value = serde_json::from_str(&found.content.unwrap()).unwrap();
|
||||||
assert_eq!(found_json["count"], 1);
|
assert_eq!(found_json["count"], 1);
|
||||||
assert_eq!(found_json["records"][0]["kind"], "blocked_by");
|
assert_eq!(found_json["records"][0]["kind"], "blocked_by");
|
||||||
assert_eq!(found_json["records"][0]["related_ticket"], second.slug);
|
assert_eq!(found_json["records"][0]["related_ticket"], second.id);
|
||||||
|
|
||||||
let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap();
|
let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap();
|
||||||
assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
|
|
@ -1625,22 +1618,26 @@ mod tests {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let show = tool_by_name(backend(&temp), "TicketShow");
|
let show = tool_by_name(backend(&temp), "TicketShow");
|
||||||
let error = show
|
let error = show
|
||||||
.execute(&json!({ "id": "a", "slug": "b" }).to_string())
|
.execute(&json!({ "id": "a", "query": "b" }).to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_create_slug_path_traversal_is_sanitized_under_backend_root() {
|
async fn ticket_create_uses_opaque_id_under_backend_root() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let create = tool_by_name(backend.clone(), "TicketCreate");
|
let create = tool_by_name(backend.clone(), "TicketCreate");
|
||||||
create
|
let output = create
|
||||||
.execute(&json!({ "title": "Escape", "slug": "../escape" }).to_string())
|
.execute(&json!({ "title": "Escape" }).to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
|
let id = value["id"].as_str().unwrap();
|
||||||
|
assert!(!id.contains("escape"));
|
||||||
assert!(!temp.path().join("escape").exists());
|
assert!(!temp.path().join("escape").exists());
|
||||||
|
assert!(temp.path().join("tickets").join(id).is_dir());
|
||||||
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user