From 0842a00238f573ecb8de7ccb1d70763e9b725f30 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Mon, 22 Jun 2026 11:36:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(rm-handlers):=20W8=20=E2=80=94=20Query=20+?= =?UTF-8?q?=20IssueRelation=20(last=20standalone=20width=20track)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W8 of the Redmine Integration Plan — the saved-views & relations track. Completes the 8 Phase-1 width tracks. # Routes - GET /queries + /queries/:name (0x010D project_query) - GET /relations + /relations/:id (0x0111 project_relation) # URL keys - Query: name-slug URL (saved-view name doubles as label + slug), percent-encoded via common::encode_path_segment. - IssueRelation: record-key URL — relations have no natural slug (identified by their from/to endpoints + type), so they key on the SurrealDB record id like W1's Issue. # Scope vs canonical - Query: canonical class carries only `name` today; the filter / column / sort payload (Redmine's `filters`/`column_names`/ `sort_criteria`, mapped onto RenderColumn in REDMINE-QUERY-HARVEST.md) lands with D2 (filters depth track). For now a Query is a named saved view. - IssueRelation: row carries `relation_type` + `lag` (Redmine `delay` / OP `lag`, canonicalised to `lag`). Wiring the from/to record links to actual Issue rows lands when the Issue detail page embeds its relations (a W1-followup). # DoD pinned by 12 new tests Store (6): query round-trip-by-name / NotFound / list; relation round-trip / NotFound / list. Handlers (6): query list empty-state 0x010D + list/detail by name (percent-encoded `Open%20bugs`) + 404; relation list empty-state 0x0111 + list/detail by record key + 404. # Status: 8/8 standalone width tracks shipped W1 Issue, W2 Project, W3 TimeEntry, W4 User+Role, W5 Taxonomy(3/4), W6 News+WikiPage, W7 Repository+Changeset, W8 Query+IssueRelation. Remaining (not standalone list/detail pages): - nested sets: W4 Member/MemberRole/Watcher, W6 Comment/Message/ Forum/Attachment — embed under Project/Issue detail pages - W5 CustomField — ties to D1 (forms depth track) - depth tracks D1 (forms) / D2 (filters+sort+group) / D3 (RBAC) / D4 (workflows) / D5 (custom fields) / D6 (search) - GUI tracks G1 (layout chrome) / G2 (CSS) / G3 (JS) cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --workspace all green; 165 tests across 5 crates. --- crates/rm-handlers/src/lib.rs | 2 + crates/rm-handlers/src/queries.rs | 196 ++++++++++++++++++++++++ crates/rm-handlers/src/relations.rs | 224 ++++++++++++++++++++++++++++ crates/rm-server/src/router.rs | 2 + crates/rm-store/src/lib.rs | 4 + crates/rm-store/src/query.rs | 105 +++++++++++++ crates/rm-store/src/relation.rs | 117 +++++++++++++++ 7 files changed, 650 insertions(+) create mode 100644 crates/rm-handlers/src/queries.rs create mode 100644 crates/rm-handlers/src/relations.rs create mode 100644 crates/rm-store/src/query.rs create mode 100644 crates/rm-store/src/relation.rs diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 5102d30..5e39cfa 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -49,6 +49,8 @@ mod common; pub mod issues; pub mod news; pub mod projects; +pub mod queries; +pub mod relations; pub mod roles; pub mod scm; pub mod taxonomy; diff --git a/crates/rm-handlers/src/queries.rs b/crates/rm-handlers/src/queries.rs new file mode 100644 index 0000000..3902ddd --- /dev/null +++ b/crates/rm-handlers/src/queries.rs @@ -0,0 +1,196 @@ +//! **W8a** — Query (`project_query` codebook id `0x010D`) saved-view +//! list + detail handlers. +//! +//! Routes: +//! - `GET /queries` — list (Name) +//! - `GET /queries/:name` — detail by name slug + +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::QueryRow; + +use crate::common::{ + encode_path_segment, html_escape, identifier_to_u64, wrap_in_doc, AppState, HandlerError, +}; + +/// `GET /queries` — render the saved-query list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let queries = state.store.list_queries().await?; + let cols = list_columns(); + let hrefs: Vec = queries + .iter() + .map(|q| format!("/queries/{}", encode_path_segment(&q.name))) + .collect(); + let ids: Vec = queries.iter().map(|q| identifier_to_u64(&q.name)).collect(); + let rows: Vec> = queries + .iter() + .enumerate() + .map(|(idx, q)| RowSource { + record_id: ids[idx], + css_classes: "query", + group: None, + inline: vec![CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &q.name, + href: &hrefs[idx], + }, + }], + block: Vec::new(), + }) + .collect(); + let body = render_list("Queries", 0x010D, "project_query", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Queries", &body))) +} + +/// `GET /queries/:name` — render a saved-query detail page. +pub async fn detail( + State(state): State, + Path(name): Path, +) -> Result, HandlerError> { + let q: QueryRow = state.store.find_query_by_name(&name).await?; + let cols = detail_columns(); + let href = format!("/queries/{}", encode_path_segment(&q.name)); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&q.name) + ); + let cells = vec![CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &q.name, + href: &href, + }, + }]; + let body = render_detail( + 0x010D, + "project_query", + identifier_to_u64(&q.name), + &headline, + &q.name, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&q.name, &body))) +} + +fn list_columns() -> [RenderColumn; 1] { + [RenderColumn::new("name", "Name", ColumnKind::PrimaryLink) + .sortable() + .frozen()] +} + +fn detail_columns() -> [RenderColumn; 1] { + [RenderColumn::new("name", "Name", ColumnKind::PrimaryLink)] +} + +/// Build the Query router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/queries", get(list)) + .route("/queries/:name", get(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::{NewQuery, Store}; + use tower::ServiceExt; + + #[tokio::test] + async fn list_empty_state() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/queries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x010D\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn list_and_detail_by_name() { + let store = Store::open().await.unwrap(); + store + .create_query(NewQuery { + name: "Open bugs".to_string(), + }) + .await + .unwrap(); + let app = router(AppState { store }); + + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/queries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()) + .unwrap(); + assert!(list.contains("Open bugs"), "{list}"); + // space in the name percent-encodes in the href. + assert!( + list.contains("href=\"/queries/Open%20bugs\""), + "expected encoded href:\n{list}" + ); + + let res = app + .oneshot( + Request::builder() + .uri("/queries/Open%20bugs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let detail = + String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()) + .unwrap(); + assert!(detail.contains("Open bugs"), "{detail}"); + assert!(detail.contains("data-class-id=\"0x010D\""), "{detail}"); + } + + #[tokio::test] + async fn detail_404_for_unknown_name() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/queries/Nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-handlers/src/relations.rs b/crates/rm-handlers/src/relations.rs new file mode 100644 index 0000000..5974ab7 --- /dev/null +++ b/crates/rm-handlers/src/relations.rs @@ -0,0 +1,224 @@ +//! **W8b** — IssueRelation (`project_relation` codebook id `0x0111`) +//! list + detail handlers. Work-item dependency edges. +//! +//! Routes: +//! - `GET /relations` — list (Type + Lag) +//! - `GET /relations/:id` — detail by record key (relations have no +//! natural slug — they're identified by endpoints + type) + +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::RelationRow; +use surrealdb_types::{RecordId, ToSql}; + +use crate::common::{html_escape, record_id_to_u64, wrap_in_doc, AppState, HandlerError}; + +/// `GET /relations` — render the relation list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let relations = state.store.list_relations().await?; + let cols = list_columns(); + let hrefs: Vec = relations + .iter() + .map(|r| { + r.id.as_ref() + .map(|rid| format!("/relations/{}", rid.key.to_sql())) + .unwrap_or_default() + }) + .collect(); + let lags: Vec = relations.iter().map(|r| r.lag.to_string()).collect(); + let ids: Vec = relations + .iter() + .map(|r| r.id.as_ref().map(record_id_to_u64).unwrap_or(0)) + .collect(); + let rows: Vec> = relations + .iter() + .enumerate() + .map(|(idx, r)| RowSource { + record_id: ids[idx], + css_classes: "relation", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &r.relation_type, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { value: &lags[idx] }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list("Relations", 0x0111, "project_relation", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Relations", &body))) +} + +/// `GET /relations/:id` — render a relation detail page. +pub async fn detail( + State(state): State, + Path(id_str): Path, +) -> Result, HandlerError> { + let rid = RecordId::new("relation", id_str.as_str()); + let rel: RelationRow = state.store.find_relation(&rid).await?; + let cols = detail_columns(); + let href = format!("/relations/{}", id_str); + let lag_str = rel.lag.to_string(); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&rel.relation_type) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &rel.relation_type, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { value: &lag_str }, + }, + ]; + let body = render_detail( + 0x0111, + "project_relation", + record_id_to_u64(&rid), + &headline, + &lag_str, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&rel.relation_type, &body))) +} + +fn list_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("relation_type", "Type", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("lag", "Lag (days)", ColumnKind::Plain).sortable(), + ] +} + +fn detail_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("relation_type", "Type", ColumnKind::PrimaryLink), + RenderColumn::new("lag", "Lag (days)", ColumnKind::Plain), + ] +} + +/// Build the Relation router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/relations", get(list)) + .route("/relations/:id", get(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::{NewRelation, Store}; + use tower::ServiceExt; + + #[tokio::test] + async fn list_empty_state() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/relations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0111\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn list_and_detail_by_record_key() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_relation(NewRelation { + relation_type: "precedes".to_string(), + lag: 3, + }) + .await + .unwrap(); + let key = inserted.id.unwrap().key.to_sql(); + let app = router(AppState { store }); + + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/relations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()) + .unwrap(); + assert!(list.contains("precedes"), "{list}"); + assert!(list.contains("href=\"/relations/"), "{list}"); + + let res = app + .oneshot( + Request::builder() + .uri(format!("/relations/{key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let detail = + String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()) + .unwrap(); + assert!(detail.contains("precedes"), "{detail}"); + assert!(detail.contains("data-class-id=\"0x0111\""), "{detail}"); + } + + #[tokio::test] + async fn detail_404_for_unknown_id() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/relations/missing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-server/src/router.rs b/crates/rm-server/src/router.rs index 9afbc89..62959c4 100644 --- a/crates/rm-server/src/router.rs +++ b/crates/rm-server/src/router.rs @@ -84,6 +84,8 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router { .merge(rm_handlers::issues::router(state.clone())) // W1: /issues .merge(rm_handlers::news::router(state.clone())) // W6a: /news .merge(rm_handlers::projects::router(state.clone())) // W2: /projects + .merge(rm_handlers::queries::router(state.clone())) // W8a: /queries + .merge(rm_handlers::relations::router(state.clone())) // W8b: /relations .merge(rm_handlers::roles::router(state.clone())) // W4b: /roles .merge(rm_handlers::scm::router(state.clone())) // W7: /repositories + /changesets .merge(rm_handlers::taxonomy::router(state.clone())) // W5: /issue_statuses + /trackers + /enumerations/issue_priorities diff --git a/crates/rm-store/src/lib.rs b/crates/rm-store/src/lib.rs index 29f40b8..c7c2f7a 100644 --- a/crates/rm-store/src/lib.rs +++ b/crates/rm-store/src/lib.rs @@ -49,6 +49,8 @@ mod error; mod issue; mod news; mod project; +mod query; +mod relation; mod role; mod scm; mod store; @@ -61,6 +63,8 @@ pub use error::StoreError; pub use issue::{IssueRow, NewIssue}; pub use news::{NewNews, NewsRow}; pub use project::{NewProject, ProjectRow}; +pub use query::{NewQuery, QueryRow}; +pub use relation::{NewRelation, RelationRow}; pub use role::{NewRole, RoleRow}; pub use scm::{ChangesetRow, NewChangeset, NewRepository, RepositoryRow}; pub use store::Store; diff --git a/crates/rm-store/src/query.rs b/crates/rm-store/src/query.rs new file mode 100644 index 0000000..468e98c --- /dev/null +++ b/crates/rm-store/src/query.rs @@ -0,0 +1,105 @@ +//! Query (`project_query` codebook id `0x010D`) — saved list-view +//! filter specs. W8 of the Redmine Integration Plan (saved-views half). +//! +//! The canonical class carries only `name` today; the actual filter / +//! column / sort payload Redmine stores (`filters`, `column_names`, +//! `sort_criteria`) lands when D2 (Plan §4 filters depth track) wires +//! the Query-as-data shape the harvest doc (`REDMINE-QUERY-HARVEST.md`) +//! mapped onto `RenderColumn`. For now a Query is a named saved view. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_query`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewQuery { + /// Saved-view name (the URL slug + list-page label). + pub name: String, +} + +/// Row returned by [`Store::find_query_by_name`] / [`Store::list_queries`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct QueryRow { + /// SurrealDB record id (`query:`). + pub id: Option, + /// Saved-view name. + pub name: String, +} + +impl Store { + /// Insert a Query. + pub async fn create_query(&self, new: NewQuery) -> Result { + let row: Option = self.db().create("query").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a Query by name (the URL slug). + pub async fn find_query_by_name(&self, name: &str) -> Result { + match self + .db() + .query("SELECT * FROM query 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)), + } + } + + /// List every Query. + pub async fn list_queries(&self) -> Result, StoreError> { + match self.db().select::>("query").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 create_then_find_by_name() { + let store = Store::open().await.unwrap(); + store + .create_query(NewQuery { + name: "Open bugs".to_string(), + }) + .await + .unwrap(); + let r = store.find_query_by_name("Open bugs").await.unwrap(); + assert_eq!(r.name, "Open bugs"); + } + + #[tokio::test] + async fn find_not_found() { + let store = Store::open().await.unwrap(); + assert!(matches!( + store.find_query_by_name("Nope").await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_queries().await.unwrap().is_empty()); + for n in ["Open bugs", "My issues"] { + store + .create_query(NewQuery { + name: n.to_string(), + }) + .await + .unwrap(); + } + assert_eq!(store.list_queries().await.unwrap().len(), 2); + } +} diff --git a/crates/rm-store/src/relation.rs b/crates/rm-store/src/relation.rs new file mode 100644 index 0000000..f69daa3 --- /dev/null +++ b/crates/rm-store/src/relation.rs @@ -0,0 +1,117 @@ +//! IssueRelation (`project_relation` codebook id `0x0111`) — +//! work-item ↔ work-item dependency edges. W8 (relations half). +//! +//! The canonical class has two family edges (`from` / `to` +//! ProjectWorkItem) plus two attributes: `relation_type` +//! (`"precedes"`, `"blocks"`, `"relates"`, …) and `lag` (Redmine's +//! `delay` / OP's `lag` — offset in days, canonicalised to `lag`). +//! +//! The MVP row carries the two attributes; wiring the `from`/`to` +//! record links to actual Issue rows lands once the Issue detail +//! page embeds its relations (a W1-followup that needs the join the +//! canonical edges describe). + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_relation`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewRelation { + /// Relation kind: `"precedes"`, `"blocks"`, `"relates"`, + /// `"duplicates"`, … (Redmine's `IssueRelation::TYPES`). + pub relation_type: String, + /// Offset in days between the two work items (Redmine `delay` / + /// OpenProject `lag`, canonicalised to `lag`). `0` for + /// non-scheduling relation types. + pub lag: i64, +} + +/// Row returned by [`Store::find_relation`] / [`Store::list_relations`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct RelationRow { + /// SurrealDB record id (`relation:`). + pub id: Option, + /// Relation kind. + pub relation_type: String, + /// Day offset. + pub lag: i64, +} + +impl Store { + /// Insert an IssueRelation. + pub async fn create_relation(&self, new: NewRelation) -> Result { + let row: Option = self.db().create("relation").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Read an IssueRelation by its SurrealDB record id. Relations + /// have no natural slug (they're identified by their endpoints + + /// type), so the URL keys on the record id like W1's Issue. + pub async fn find_relation(&self, id: &RecordId) -> Result { + match self.db().select(id.clone()).await { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every IssueRelation, in insertion order. + pub async fn list_relations(&self) -> Result, StoreError> { + match self.db().select::>("relation").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 create_then_find_round_trips() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_relation(NewRelation { + relation_type: "precedes".to_string(), + lag: 2, + }) + .await + .unwrap(); + assert_eq!(inserted.relation_type, "precedes"); + assert_eq!(inserted.lag, 2); + let id = inserted.id.clone().unwrap(); + let fetched = store.find_relation(&id).await.unwrap(); + assert_eq!(fetched.relation_type, "precedes"); + assert_eq!(fetched.lag, 2); + } + + #[tokio::test] + async fn find_not_found() { + let store = Store::open().await.unwrap(); + let id = RecordId::new("relation", "missing"); + assert!(matches!( + store.find_relation(&id).await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_relations().await.unwrap().is_empty()); + for t in ["precedes", "blocks"] { + store + .create_relation(NewRelation { + relation_type: t.to_string(), + lag: 0, + }) + .await + .unwrap(); + } + assert_eq!(store.list_relations().await.unwrap().len(), 2); + } +}