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