diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 9f518dc..31e145b 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -49,6 +49,7 @@ mod common; pub mod issues; pub mod projects; pub mod roles; +pub mod taxonomy; pub mod time_entries; pub mod users; diff --git a/crates/rm-handlers/src/taxonomy.rs b/crates/rm-handlers/src/taxonomy.rs new file mode 100644 index 0000000..806b80b --- /dev/null +++ b/crates/rm-handlers/src/taxonomy.rs @@ -0,0 +1,509 @@ +//! **W5** — Taxonomy admin pages: IssueStatus, Tracker, IssuePriority. +//! +//! Three near-identical lookup resources sharing the +//! `(name, position, is_)` shape. Each routes through a +//! distinct OGAR class_id so the rendered HTML carries the right +//! `data-class-id` attribute for downstream JS hooks. +//! +//! Routes (mounted by [`router`]): +//! +//! - `GET /issue_statuses` + `/issue_statuses/:name` (0x0105) +//! - `GET /trackers` + `/trackers/:name` (0x0106) +//! - `GET /enumerations/issue_priorities` + `.../issue_priorities/:name` (0x0107) +//! +//! The IssuePriority URL follows Redmine's convention +//! (`/enumerations/issue_priorities` — Enumerations group lookup +//! tables of variable schemas, and Redmine ships several including +//! TimeEntryActivity + DocumentCategory that we don't model yet). + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::{IssuePriorityRow, IssueStatusRow, TrackerRow}; + +use crate::common::{ + encode_path_segment, html_escape, identifier_to_u64, wrap_in_doc, AppState, HandlerError, +}; + +// ── IssueStatus handlers (0x0105) ─────────────────────────────────── + +/// `GET /issue_statuses` — render the issue-status list. +pub async fn issue_status_list( + State(state): State, +) -> Result, HandlerError> { + let rows = state.store.list_issue_statuses().await?; + let cols = list_columns_with_flag("Closed?"); + let hrefs: Vec = rows + .iter() + .map(|r| format!("/issue_statuses/{}", encode_path_segment(&r.name))) + .collect(); + let positions: Vec = rows.iter().map(|r| r.position.to_string()).collect(); + let flags: Vec<&'static str> = rows + .iter() + .map(|r| if r.is_closed { "yes" } else { "no" }) + .collect(); + let ids: Vec = rows.iter().map(|r| identifier_to_u64(&r.name)).collect(); + let row_sources: Vec> = rows + .iter() + .enumerate() + .map(|(idx, r)| { + build_lookup_row( + idx, + ids[idx], + &r.name, + &hrefs[idx], + &positions[idx], + flags[idx], + &cols, + "issue-status", + ) + }) + .collect(); + let body = render_list( + "Issue statuses", + 0x0105, + "project_status", + &cols, + &[], + &row_sources, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Issue statuses", &body))) +} + +/// `GET /issue_statuses/:name` — render an issue-status detail page. +pub async fn issue_status_detail( + State(state): State, + Path(name): Path, +) -> Result, HandlerError> { + let row: IssueStatusRow = state.store.find_issue_status_by_name(&name).await?; + let href = format!("/issue_statuses/{}", encode_path_segment(&row.name)); + let position_str = row.position.to_string(); + let flag_str = if row.is_closed { "yes" } else { "no" }; + detail_render( + 0x0105, + "project_status", + &row.name, + &href, + &position_str, + flag_str, + "Closed?", + ) +} + +// ── Tracker handlers (0x0106) ─────────────────────────────────────── + +/// `GET /trackers` — render the tracker list. +pub async fn tracker_list(State(state): State) -> Result, HandlerError> { + let rows = state.store.list_trackers().await?; + let cols = list_columns_with_flag("Default?"); + let hrefs: Vec = rows + .iter() + .map(|r| format!("/trackers/{}", encode_path_segment(&r.name))) + .collect(); + let positions: Vec = rows.iter().map(|r| r.position.to_string()).collect(); + let flags: Vec<&'static str> = rows + .iter() + .map(|r| if r.is_default { "yes" } else { "no" }) + .collect(); + let ids: Vec = rows.iter().map(|r| identifier_to_u64(&r.name)).collect(); + let row_sources: Vec> = rows + .iter() + .enumerate() + .map(|(idx, r)| { + build_lookup_row( + idx, + ids[idx], + &r.name, + &hrefs[idx], + &positions[idx], + flags[idx], + &cols, + "tracker", + ) + }) + .collect(); + let body = render_list("Trackers", 0x0106, "project_type", &cols, &[], &row_sources) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Trackers", &body))) +} + +/// `GET /trackers/:name` — render a tracker detail page. +pub async fn tracker_detail( + State(state): State, + Path(name): Path, +) -> Result, HandlerError> { + let row: TrackerRow = state.store.find_tracker_by_name(&name).await?; + let href = format!("/trackers/{}", encode_path_segment(&row.name)); + let position_str = row.position.to_string(); + let flag_str = if row.is_default { "yes" } else { "no" }; + detail_render( + 0x0106, + "project_type", + &row.name, + &href, + &position_str, + flag_str, + "Default?", + ) +} + +// ── IssuePriority handlers (0x0107) ───────────────────────────────── + +/// `GET /enumerations/issue_priorities` — render the issue-priority list. +pub async fn issue_priority_list( + State(state): State, +) -> Result, HandlerError> { + let rows = state.store.list_issue_priorities().await?; + let cols = list_columns_with_flag("Default?"); + let hrefs: Vec = rows + .iter() + .map(|r| { + format!( + "/enumerations/issue_priorities/{}", + encode_path_segment(&r.name) + ) + }) + .collect(); + let positions: Vec = rows.iter().map(|r| r.position.to_string()).collect(); + let flags: Vec<&'static str> = rows + .iter() + .map(|r| if r.is_default { "yes" } else { "no" }) + .collect(); + let ids: Vec = rows.iter().map(|r| identifier_to_u64(&r.name)).collect(); + let row_sources: Vec> = rows + .iter() + .enumerate() + .map(|(idx, r)| { + build_lookup_row( + idx, + ids[idx], + &r.name, + &hrefs[idx], + &positions[idx], + flags[idx], + &cols, + "issue-priority", + ) + }) + .collect(); + let body = render_list( + "Issue priorities", + 0x0107, + "priority", + &cols, + &[], + &row_sources, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Issue priorities", &body))) +} + +/// `GET /enumerations/issue_priorities/:name` — render a priority detail page. +pub async fn issue_priority_detail( + State(state): State, + Path(name): Path, +) -> Result, HandlerError> { + let row: IssuePriorityRow = state.store.find_issue_priority_by_name(&name).await?; + let href = format!( + "/enumerations/issue_priorities/{}", + encode_path_segment(&row.name) + ); + let position_str = row.position.to_string(); + let flag_str = if row.is_default { "yes" } else { "no" }; + detail_render( + 0x0107, + "priority", + &row.name, + &href, + &position_str, + flag_str, + "Default?", + ) +} + +// ── Shared row + render helpers ───────────────────────────────────── + +fn list_columns_with_flag(flag_caption: &'static str) -> [RenderColumn; 3] { + [ + RenderColumn::new("name", "Name", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("position", "Position", ColumnKind::Plain).sortable(), + RenderColumn::new("flag", flag_caption, ColumnKind::Plain), + ] +} + +fn detail_columns(flag_caption: &'static str) -> [RenderColumn; 3] { + [ + RenderColumn::new("name", "Name", ColumnKind::PrimaryLink), + RenderColumn::new("position", "Position", ColumnKind::Plain), + RenderColumn::new("flag", flag_caption, ColumnKind::Plain), + ] +} + +#[allow(clippy::too_many_arguments)] +fn build_lookup_row<'a>( + _idx: usize, + record_id: u64, + name: &'a str, + href: &'a str, + position: &'a str, + flag: &'a str, + cols: &'a [RenderColumn; 3], + css_class: &'static str, +) -> RowSource<'a> { + RowSource { + record_id, + css_classes: css_class, + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { label: name, href }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { value: position }, + }, + CellSource { + column: &cols[2], + css_classes: "flag", + data: CellData::Plain { value: flag }, + }, + ], + block: Vec::new(), + } +} + +fn detail_render( + class_id: u16, + concept: &'static str, + name: &str, + href: &str, + position_str: &str, + flag_str: &str, + flag_caption: &'static str, +) -> Result, HandlerError> { + let cols = detail_columns(flag_caption); + let headline = format!( + "{}", + html_escape(href), + html_escape(name) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { label: name, href }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { + value: position_str, + }, + }, + CellSource { + column: &cols[2], + css_classes: "flag", + data: CellData::Plain { value: flag_str }, + }, + ]; + let body = render_detail( + class_id, + concept, + identifier_to_u64(name), + &headline, + position_str, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(name, &body))) +} + +// ── Router ────────────────────────────────────────────────────────── + +/// Build the Taxonomy router (mounts all three resources). One +/// merge call in rm-server brings the lot. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/issue_statuses", get(issue_status_list)) + .route("/issue_statuses/:name", get(issue_status_detail)) + .route("/trackers", get(tracker_list)) + .route("/trackers/:name", get(tracker_detail)) + .route("/enumerations/issue_priorities", get(issue_priority_list)) + .route( + "/enumerations/issue_priorities/:name", + get(issue_priority_detail), + ) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewIssuePriority, NewIssueStatus, NewTracker, Store}; + use tower::ServiceExt; + + async fn store_with_seed() -> Store { + let store = Store::open().await.unwrap(); + store + .create_issue_status(NewIssueStatus { + name: "New".to_string(), + position: 1, + is_closed: false, + }) + .await + .unwrap(); + store + .create_issue_status(NewIssueStatus { + name: "Closed".to_string(), + position: 99, + is_closed: true, + }) + .await + .unwrap(); + store + .create_tracker(NewTracker { + name: "Bug".to_string(), + position: 1, + is_default: true, + }) + .await + .unwrap(); + store + .create_issue_priority(NewIssuePriority { + name: "Normal".to_string(), + position: 2, + is_default: true, + }) + .await + .unwrap(); + store + } + + async fn issue_status_list_body(store: Store) -> String { + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/issue_statuses") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap() + } + + #[tokio::test] + async fn issue_status_list_renders_canonical_class_id() { + let store = store_with_seed().await; + let s = issue_status_list_body(store).await; + assert!(s.contains("data-class-id=\"0x0105\""), "{s}"); + assert!(s.contains("New")); + assert!(s.contains("Closed")); + } + + #[tokio::test] + async fn tracker_list_renders_default_flag() { + let app = router(AppState { + store: store_with_seed().await, + }); + let res = app + .oneshot( + Request::builder() + .uri("/trackers") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0106\"")); + assert!(s.contains("Bug")); + assert!(s.contains("href=\"/trackers/Bug\"")); + assert!(s.contains("yes"), "expected `yes` flag in:\n{s}"); + } + + #[tokio::test] + async fn issue_priority_list_uses_enumerations_url() { + let app = router(AppState { + store: store_with_seed().await, + }); + let res = app + .oneshot( + Request::builder() + .uri("/enumerations/issue_priorities") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0107\"")); + assert!(s.contains("Normal")); + assert!( + s.contains("href=\"/enumerations/issue_priorities/Normal\""), + "expected Redmine-shape Enumerations URL:\n{s}" + ); + } + + #[tokio::test] + async fn detail_pages_render_for_each_resource() { + let app = router(AppState { + store: store_with_seed().await, + }); + for (path, class_id) in [ + ("/issue_statuses/Closed", "0x0105"), + ("/trackers/Bug", "0x0106"), + ("/enumerations/issue_priorities/Normal", "0x0107"), + ] { + let res = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK, "path: {path}"); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!( + s.contains(&format!("data-class-id=\"{class_id}\"")), + "path {path} should carry {class_id}:\n{s}" + ); + } + } + + #[tokio::test] + async fn detail_404_for_unknown_name_across_resources() { + let app = router(AppState { + store: Store::open().await.unwrap(), + }); + for path in [ + "/issue_statuses/Nope", + "/trackers/Nope", + "/enumerations/issue_priorities/Nope", + ] { + let res = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}"); + } + } +} diff --git a/crates/rm-server/src/router.rs b/crates/rm-server/src/router.rs index 0ce1233..96933ef 100644 --- a/crates/rm-server/src/router.rs +++ b/crates/rm-server/src/router.rs @@ -84,6 +84,7 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router { .merge(rm_handlers::issues::router(state.clone())) // W1: /issues .merge(rm_handlers::projects::router(state.clone())) // W2: /projects .merge(rm_handlers::roles::router(state.clone())) // W4b: /roles + .merge(rm_handlers::taxonomy::router(state.clone())) // W5: /issue_statuses + /trackers + /enumerations/issue_priorities .merge(rm_handlers::time_entries::router(state.clone())) // W3: /time_entries .merge(rm_handlers::users::router(state.clone())) // W4a: /users // ── Phase-0 auxiliary surfaces ── diff --git a/crates/rm-store/src/lib.rs b/crates/rm-store/src/lib.rs index 88b8595..b662b12 100644 --- a/crates/rm-store/src/lib.rs +++ b/crates/rm-store/src/lib.rs @@ -50,6 +50,7 @@ mod issue; mod project; mod role; mod store; +mod taxonomy; mod time_entry; mod user; @@ -58,5 +59,8 @@ pub use issue::{IssueRow, NewIssue}; pub use project::{NewProject, ProjectRow}; pub use role::{NewRole, RoleRow}; pub use store::Store; +pub use taxonomy::{ + IssuePriorityRow, IssueStatusRow, NewIssuePriority, NewIssueStatus, NewTracker, TrackerRow, +}; pub use time_entry::{NewTimeEntry, TimeEntryRow}; pub use user::{NewUser, UserRow}; diff --git a/crates/rm-store/src/taxonomy.rs b/crates/rm-store/src/taxonomy.rs new file mode 100644 index 0000000..9fc219c --- /dev/null +++ b/crates/rm-store/src/taxonomy.rs @@ -0,0 +1,301 @@ +//! Taxonomy lookups (W5) — IssueStatus, Tracker, IssuePriority. +//! +//! Three near-identical admin lookup tables sharing the +//! `(name, position, is_)` shape. Codebook ids: +//! +//! | Concept | Class id | Redmine name | OP name | +//! |---|---|---|---| +//! | IssueStatus | `0x0105 project_status` | IssueStatus | Status | +//! | Tracker | `0x0106 project_type` | Tracker | Type | +//! | IssuePriority | `0x0107 priority` | IssuePriority | (Priority) | +//! +//! Each ports through `ogar_vocab::ports::*Port::class_id` to the +//! same canonical id, so Redmine `IssueStatus` and OpenProject +//! `Status` both render via `0x0105`. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +// ── IssueStatus ──────────────────────────────────────────────────── + +/// Input for [`Store::create_issue_status`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewIssueStatus { + /// Status name (`"New"`, `"In Progress"`, `"Closed"`, …). + pub name: String, + /// Sort position. + pub position: i64, + /// Whether issues in this status count as "closed" for filtering / + /// percent-done aggregation. + pub is_closed: bool, +} + +/// Row returned by [`Store::find_issue_status_by_name`] / list_issue_statuses. +#[derive(Debug, Clone, SurrealValue)] +pub struct IssueStatusRow { + /// SurrealDB record id (`issue_status:`). + pub id: Option, + /// Status name. + pub name: String, + /// Sort position. + pub position: i64, + /// True when this status counts as closed. + pub is_closed: bool, +} + +// ── Tracker ──────────────────────────────────────────────────────── + +/// Input for [`Store::create_tracker`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewTracker { + /// Tracker name (`"Bug"`, `"Feature"`, `"Support"`, …). + pub name: String, + /// Sort position. + pub position: i64, + /// True for the tracker used when one isn't explicitly set on + /// new issues. + pub is_default: bool, +} + +/// Row returned by [`Store::find_tracker_by_name`] / list_trackers. +#[derive(Debug, Clone, SurrealValue)] +pub struct TrackerRow { + /// SurrealDB record id. + pub id: Option, + /// Tracker name. + pub name: String, + /// Sort position. + pub position: i64, + /// Default-tracker flag. + pub is_default: bool, +} + +// ── IssuePriority ────────────────────────────────────────────────── + +/// Input for [`Store::create_issue_priority`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewIssuePriority { + /// Priority name (`"Low"`, `"Normal"`, `"High"`, `"Urgent"`, …). + pub name: String, + /// Sort position. + pub position: i64, + /// True for the default-priority value applied to new issues. + pub is_default: bool, +} + +/// Row returned by [`Store::find_issue_priority_by_name`] / list_issue_priorities. +#[derive(Debug, Clone, SurrealValue)] +pub struct IssuePriorityRow { + /// SurrealDB record id. + pub id: Option, + /// Priority name. + pub name: String, + /// Sort position. + pub position: i64, + /// Default-priority flag. + pub is_default: bool, +} + +// ── Store impls ──────────────────────────────────────────────────── + +impl Store { + /// Insert an IssueStatus. + pub async fn create_issue_status( + &self, + new: NewIssueStatus, + ) -> Result { + let row: Option = self.db().create("issue_status").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find an IssueStatus by name. + pub async fn find_issue_status_by_name( + &self, + name: &str, + ) -> Result { + find_by_name(self, "issue_status", name).await + } + + /// List IssueStatuses. + pub async fn list_issue_statuses(&self) -> Result, StoreError> { + list_table(self, "issue_status").await + } + + /// Insert a Tracker. + pub async fn create_tracker(&self, new: NewTracker) -> Result { + let row: Option = self.db().create("tracker").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a Tracker by name. + pub async fn find_tracker_by_name(&self, name: &str) -> Result { + find_by_name(self, "tracker", name).await + } + + /// List Trackers. + pub async fn list_trackers(&self) -> Result, StoreError> { + list_table(self, "tracker").await + } + + /// Insert an IssuePriority. + pub async fn create_issue_priority( + &self, + new: NewIssuePriority, + ) -> Result { + let row: Option = self.db().create("issue_priority").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find an IssuePriority by name. + pub async fn find_issue_priority_by_name( + &self, + name: &str, + ) -> Result { + find_by_name(self, "issue_priority", name).await + } + + /// List IssuePriorities. + pub async fn list_issue_priorities(&self) -> Result, StoreError> { + list_table(self, "issue_priority").await + } +} + +// ── Shared internals ─────────────────────────────────────────────── + +/// Generic "select first row WHERE name = $n" over a SurrealValue-shaped +/// row type. Three concrete callers share this; the W6+ taxonomy +/// candidates (Activity, EnumerationTimeEntryActivity, etc.) will too. +async fn find_by_name(store: &Store, table: &'static str, name: &str) -> Result +where + T: SurrealValue, +{ + match store + .db() + .query(format!("SELECT * FROM {table} WHERE name = $n LIMIT 1")) + .bind(("n", name.to_string())) + .await + { + Ok(mut res) => match res.take::>(0) { + Ok(rows) => rows.into_iter().next().ok_or(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + }, + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } +} + +async fn list_table(store: &Store, table: &'static str) -> Result, StoreError> +where + T: SurrealValue, +{ + match store.db().select::>(table).await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn issue_status_round_trips() { + let store = Store::open().await.unwrap(); + store + .create_issue_status(NewIssueStatus { + name: "Closed".to_string(), + position: 99, + is_closed: true, + }) + .await + .unwrap(); + let r = store.find_issue_status_by_name("Closed").await.unwrap(); + assert!(r.is_closed); + assert_eq!(r.position, 99); + } + + #[tokio::test] + async fn tracker_round_trips() { + let store = Store::open().await.unwrap(); + store + .create_tracker(NewTracker { + name: "Bug".to_string(), + position: 1, + is_default: true, + }) + .await + .unwrap(); + let r = store.find_tracker_by_name("Bug").await.unwrap(); + assert!(r.is_default); + } + + #[tokio::test] + async fn issue_priority_round_trips() { + let store = Store::open().await.unwrap(); + store + .create_issue_priority(NewIssuePriority { + name: "Normal".to_string(), + position: 2, + is_default: true, + }) + .await + .unwrap(); + let r = store.find_issue_priority_by_name("Normal").await.unwrap(); + assert!(r.is_default); + } + + #[tokio::test] + async fn list_empty_then_populated_for_each_table() { + let store = Store::open().await.unwrap(); + assert!(store.list_issue_statuses().await.unwrap().is_empty()); + assert!(store.list_trackers().await.unwrap().is_empty()); + assert!(store.list_issue_priorities().await.unwrap().is_empty()); + store + .create_issue_status(NewIssueStatus { + name: "New".to_string(), + position: 1, + is_closed: false, + }) + .await + .unwrap(); + store + .create_tracker(NewTracker { + name: "Bug".to_string(), + position: 1, + is_default: true, + }) + .await + .unwrap(); + store + .create_issue_priority(NewIssuePriority { + name: "Low".to_string(), + position: 1, + is_default: false, + }) + .await + .unwrap(); + assert_eq!(store.list_issue_statuses().await.unwrap().len(), 1); + assert_eq!(store.list_trackers().await.unwrap().len(), 1); + assert_eq!(store.list_issue_priorities().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn find_returns_not_found_for_unknown() { + let store = Store::open().await.unwrap(); + assert!(matches!( + store.find_issue_status_by_name("Nope").await, + Err(StoreError::NotFound) + )); + assert!(matches!( + store.find_tracker_by_name("Nope").await, + Err(StoreError::NotFound) + )); + assert!(matches!( + store.find_issue_priority_by_name("Nope").await, + Err(StoreError::NotFound) + )); + } +}