ticket: update list query semantics
This commit is contained in:
+131
-26
@@ -510,18 +510,75 @@ impl NewTicket {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketFilter {
|
||||
pub state: Option<TicketWorkflowState>,
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketStateSelector {
|
||||
/// All non-closed workflow states: planning, ready, queued, inprogress, and done.
|
||||
Active,
|
||||
/// Every workflow state, including closed.
|
||||
All,
|
||||
/// An explicit set of workflow states.
|
||||
States(BTreeSet<TicketWorkflowState>),
|
||||
}
|
||||
|
||||
impl TicketFilter {
|
||||
impl Default for TicketStateSelector {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketListQuery {
|
||||
pub state: TicketStateSelector,
|
||||
}
|
||||
|
||||
impl Default for TicketListQuery {
|
||||
fn default() -> Self {
|
||||
Self::active()
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketListQuery {
|
||||
pub fn active() -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::Active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> Self {
|
||||
Self { state: None }
|
||||
Self {
|
||||
state: TicketStateSelector::All,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(state: TicketWorkflowState) -> Self {
|
||||
Self { state: Some(state) }
|
||||
Self::states([state])
|
||||
}
|
||||
|
||||
pub fn states(states: impl IntoIterator<Item = TicketWorkflowState>) -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::States(states.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_state(&self, state: TicketWorkflowState) -> bool {
|
||||
match &self.state {
|
||||
TicketStateSelector::Active => state != TicketWorkflowState::Closed,
|
||||
TicketStateSelector::All => true,
|
||||
TicketStateSelector::States(states) => states.contains(&state),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_filter_label(&self) -> String {
|
||||
match &self.state {
|
||||
TicketStateSelector::Active => "active".to_string(),
|
||||
TicketStateSelector::All => "all".to_string(),
|
||||
TicketStateSelector::States(states) => states
|
||||
.iter()
|
||||
.map(|state| state.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,7 +930,7 @@ impl TicketDoctorReport {
|
||||
|
||||
pub trait TicketBackend {
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>;
|
||||
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
|
||||
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
|
||||
fn create(&self, input: NewTicket) -> Result<TicketRef>;
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
|
||||
@@ -926,7 +983,7 @@ pub enum TicketBackendOperation {
|
||||
from: String,
|
||||
},
|
||||
List {
|
||||
filter: TicketFilter,
|
||||
filter: TicketListQuery,
|
||||
},
|
||||
Show {
|
||||
id: TicketIdOrSlug,
|
||||
@@ -1143,10 +1200,10 @@ impl LocalTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
|
||||
pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
|
||||
let mut output = TicketPartialList::default();
|
||||
let mut invalid_seen = BTreeSet::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
if !item.exists() {
|
||||
continue;
|
||||
@@ -1155,10 +1212,7 @@ impl LocalTicketBackend {
|
||||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||
{
|
||||
Ok(meta) => {
|
||||
if filter
|
||||
.state
|
||||
.is_some_and(|state| meta.workflow_state != state)
|
||||
{
|
||||
if !filter.matches_state(meta.workflow_state) {
|
||||
continue;
|
||||
}
|
||||
output.tickets.push(ticket_summary_from_meta(meta));
|
||||
@@ -1255,7 +1309,7 @@ impl LocalTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> {
|
||||
fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
|
||||
let mut dirs = Vec::new();
|
||||
if !self.root.exists() {
|
||||
return Ok(dirs);
|
||||
@@ -1275,10 +1329,10 @@ impl LocalTicketBackend {
|
||||
if !item.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(state) = filter.state {
|
||||
if !matches!(filter.state, TicketStateSelector::All) {
|
||||
let parsed = read_item_file(&item)?;
|
||||
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
|
||||
if meta.workflow_state != state {
|
||||
if !filter.matches_state(meta.workflow_state) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1495,7 +1549,7 @@ impl LocalTicketBackend {
|
||||
|
||||
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
|
||||
let mut relations = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
||||
}
|
||||
sort_ticket_relations(&mut relations);
|
||||
@@ -1508,7 +1562,7 @@ impl LocalTicketBackend {
|
||||
invalid_seen: &mut BTreeSet<String>,
|
||||
) -> Result<Vec<TicketRelation>> {
|
||||
let mut relations = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
match self.read_ticket_relations_for_dir(&dir) {
|
||||
Ok(records) => relations.extend(records),
|
||||
Err(error) => {
|
||||
@@ -1539,7 +1593,7 @@ impl LocalTicketBackend {
|
||||
|
||||
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||
let mut states = HashMap::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
||||
states.insert(meta.id, meta.workflow_state);
|
||||
@@ -1553,7 +1607,7 @@ impl LocalTicketBackend {
|
||||
invalid_seen: &mut BTreeSet<String>,
|
||||
) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||
let mut states = HashMap::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
match read_item_file(&item)
|
||||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||
@@ -1589,7 +1643,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
self.default_intake_ready_state_change_body(from)
|
||||
}
|
||||
|
||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
|
||||
let mut tickets = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(filter)? {
|
||||
let item = dir.join("item.md");
|
||||
@@ -2088,7 +2142,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
let dir = self.find_ticket_dir(&ticket)?;
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
} else {
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
}
|
||||
}
|
||||
@@ -2117,7 +2171,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let ticket_id = match ticket_id_from_dir(&dir) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
@@ -3975,6 +4029,57 @@ state: planning
|
||||
assert!(err.contains("invalid YAML frontmatter"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_query_defaults_to_active_and_supports_all_or_explicit_states() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||
let closed = backend.create(closed_input).unwrap();
|
||||
|
||||
let active = backend.list(TicketListQuery::default()).unwrap();
|
||||
let active_ids = active
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(active_ids.contains(&planning.id.as_str()));
|
||||
assert!(active_ids.contains(&ready.id.as_str()));
|
||||
assert!(!active_ids.contains(&closed.id.as_str()));
|
||||
|
||||
let all = backend.list(TicketListQuery::all()).unwrap();
|
||||
let all_ids = all
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(all_ids.contains(&planning.id.as_str()));
|
||||
assert!(all_ids.contains(&ready.id.as_str()));
|
||||
assert!(all_ids.contains(&closed.id.as_str()));
|
||||
|
||||
let ready_only = backend
|
||||
.list(TicketListQuery::state(TicketWorkflowState::Ready))
|
||||
.unwrap();
|
||||
assert_eq!(ready_only.len(), 1);
|
||||
assert_eq!(ready_only[0].id, ready.id);
|
||||
|
||||
let planning_or_closed = backend
|
||||
.list(TicketListQuery::states([
|
||||
TicketWorkflowState::Planning,
|
||||
TicketWorkflowState::Closed,
|
||||
]))
|
||||
.unwrap();
|
||||
let explicit_ids = planning_or_closed
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(explicit_ids.contains(&planning.id.as_str()));
|
||||
assert!(explicit_ids.contains(&closed.id.as_str()));
|
||||
assert!(!explicit_ids.contains(&ready.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_writes_local_ticket_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -4036,9 +4141,9 @@ state: planning
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(backend.list(TicketFilter::all()).is_err());
|
||||
assert!(backend.list(TicketListQuery::all()).is_err());
|
||||
|
||||
let partial = backend.list_partial(TicketFilter::all()).unwrap();
|
||||
let partial = backend.list_partial(TicketListQuery::all()).unwrap();
|
||||
assert_eq!(partial.tickets.len(), 1);
|
||||
assert_eq!(partial.tickets[0].id, valid.id);
|
||||
assert_eq!(partial.invalid_records.len(), 1);
|
||||
|
||||
+112
-35
@@ -97,8 +97,8 @@ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed T
|
||||
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
||||
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
||||
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
||||
lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
|
||||
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \
|
||||
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||
routing, closing, planning, or implementation decisions.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||
@@ -214,7 +214,7 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.default_intake_ready_state_change_body(from)
|
||||
}
|
||||
|
||||
fn list(&self, filter: crate::TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||
self.backend.list(filter)
|
||||
}
|
||||
|
||||
@@ -375,9 +375,10 @@ impl TicketWorkflowStateParam {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketListStateParam {
|
||||
Active,
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
@@ -388,47 +389,62 @@ enum TicketListStateParam {
|
||||
}
|
||||
|
||||
impl TicketListStateParam {
|
||||
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
|
||||
fn as_state(self) -> Option<TicketWorkflowState> {
|
||||
match self {
|
||||
Self::Planning => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Planning),
|
||||
"planning",
|
||||
),
|
||||
Self::Ready => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Ready),
|
||||
"ready",
|
||||
),
|
||||
Self::Queued => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Queued),
|
||||
"queued",
|
||||
),
|
||||
Self::Inprogress => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::InProgress),
|
||||
"inprogress",
|
||||
),
|
||||
Self::Done => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Done),
|
||||
"done",
|
||||
),
|
||||
Self::Closed => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Closed),
|
||||
"closed",
|
||||
),
|
||||
Self::All => (crate::TicketFilter::all(), "all"),
|
||||
Self::Planning => Some(TicketWorkflowState::Planning),
|
||||
Self::Ready => Some(TicketWorkflowState::Ready),
|
||||
Self::Queued => Some(TicketWorkflowState::Queued),
|
||||
Self::Inprogress => Some(TicketWorkflowState::InProgress),
|
||||
Self::Done => Some(TicketWorkflowState::Done),
|
||||
Self::Closed => Some(TicketWorkflowState::Closed),
|
||||
Self::Active | Self::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketListParams {
|
||||
/// State filter. Defaults to all Tickets.
|
||||
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
|
||||
#[serde(default)]
|
||||
state: Option<TicketListStateParam>,
|
||||
/// Explicit workflow-state filter list. Cannot be combined with `state`.
|
||||
#[serde(default)]
|
||||
states: Option<Vec<TicketWorkflowStateParam>>,
|
||||
/// Maximum number of summaries to return. Defaults to 50, max 100.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl TicketListParams {
|
||||
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
|
||||
let query = if let Some(states) = self.states {
|
||||
if self.state.is_some() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList accepts either `state` or `states`, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if states.is_empty() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList `states` must include at least one workflow state".to_string(),
|
||||
));
|
||||
}
|
||||
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_state()))
|
||||
} else {
|
||||
match self.state.unwrap_or(TicketListStateParam::Active) {
|
||||
TicketListStateParam::Active => crate::TicketListQuery::active(),
|
||||
TicketListStateParam::All => crate::TicketListQuery::all(),
|
||||
state => crate::TicketListQuery::state(
|
||||
state
|
||||
.as_state()
|
||||
.expect("workflow state list param maps to TicketWorkflowState"),
|
||||
),
|
||||
}
|
||||
};
|
||||
let label = query.state_filter_label();
|
||||
Ok((query, label, self.limit))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketShowParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
@@ -825,9 +841,10 @@ impl Tool for TicketListTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||
let state = params.state.unwrap_or(TicketListStateParam::All);
|
||||
let (filter, state_filter) = state.as_filter();
|
||||
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let (filter, state_filter, params_limit) = params
|
||||
.into_query()
|
||||
.map_err(|error| backend_error("TicketList", error))?;
|
||||
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let tickets = self
|
||||
.backend
|
||||
.list(filter)
|
||||
@@ -1825,6 +1842,16 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let active = list
|
||||
.execute(&json!({}).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap();
|
||||
assert_eq!(active_json["state_filter"], "active");
|
||||
assert_eq!(active_json["count"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["returned"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["truncated"].as_bool(), Some(false));
|
||||
|
||||
let all = list
|
||||
.execute(&json!({ "state": "all" }).to_string(), Default::default())
|
||||
.await
|
||||
@@ -1857,6 +1884,53 @@ mod tests {
|
||||
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||
let closed = backend.create(closed_input).unwrap();
|
||||
|
||||
let listed = list
|
||||
.execute(
|
||||
&json!({ "states": ["planning", "closed"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap();
|
||||
assert_eq!(listed_json["state_filter"], "planning,closed");
|
||||
assert_eq!(listed_json["count"].as_u64(), Some(2));
|
||||
let listed_ids = listed_json["tickets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|ticket| ticket["id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(listed_ids.contains(&planning.id.as_str()));
|
||||
assert!(listed_ids.contains(&closed.id.as_str()));
|
||||
assert!(!listed_ids.contains(&ready.id.as_str()));
|
||||
|
||||
let mixed = list
|
||||
.execute(
|
||||
&json!({ "state": "active", "states": ["planning"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(mixed.is_err());
|
||||
|
||||
let empty = list
|
||||
.execute(&json!({ "states": [] }).to_string(), Default::default())
|
||||
.await;
|
||||
assert!(empty.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -2408,7 +2482,10 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(
|
||||
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user