test: update ticket schema expectations

This commit is contained in:
Keisuke Hirata 2026-06-09 12:15:58 +09:00
parent 191a875f5a
commit 591db3ff72
No known key found for this signature in database
2 changed files with 136 additions and 151 deletions

View File

@ -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"),
)
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);

View File

@ -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);
}