use std::fmt;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::error::{HonchoError, Result};
use crate::http::client::HttpClient;
use crate::http::routes;
use crate::types::conclusion::Conclusion as ConclusionData;
use crate::types::conclusion::ConclusionPage;
use crate::types::conclusion::{ConclusionBatchCreate, ConclusionCreate};
use crate::types::conclusion::{ConclusionFilters, ConclusionGet, ConclusionQuery};
use crate::types::dialectic::RepresentationResponse;
use crate::types::pagination::paginate_post;
use crate::types::session::validate_search_params;
pub(crate) struct ConclusionInner {
workspace_id: String,
id: String,
content: String,
observer_id: String,
observed_id: String,
session_id: Option<String>,
created_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct Conclusion {
inner: Arc<ConclusionInner>,
}
impl Conclusion {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn from_parts(workspace_id: String, resp: ConclusionData) -> Self {
Self {
inner: Arc::new(ConclusionInner {
workspace_id,
id: resp.id,
content: resp.content,
observer_id: resp.observer_id,
observed_id: resp.observed_id,
session_id: resp.session_id,
created_at: resp.created_at,
}),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.inner.id
}
#[must_use]
pub fn content(&self) -> &str {
&self.inner.content
}
#[must_use]
pub fn observer_id(&self) -> &str {
&self.inner.observer_id
}
#[must_use]
pub fn observed_id(&self) -> &str {
&self.inner.observed_id
}
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.inner.session_id.as_deref()
}
#[must_use]
pub fn created_at(&self) -> &DateTime<Utc> {
&self.inner.created_at
}
#[must_use]
pub fn workspace_id(&self) -> &str {
&self.inner.workspace_id
}
}
impl fmt::Debug for Conclusion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let truncated = if self.inner.content.len() > 50 {
let end = self
.inner
.content
.char_indices()
.nth(50)
.map_or(self.inner.content.len(), |(i, _)| i);
&self.inner.content[..end]
} else {
&self.inner.content
};
f.debug_struct("Conclusion")
.field("id", &self.inner.id)
.field("content", &truncated)
.finish()
}
}
impl fmt::Display for Conclusion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.inner.content)
}
}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[builder(on(String, into))]
#[builder(finish_fn = build)]
pub struct ConclusionCreateParams {
pub(crate) content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_id: Option<String>,
}
impl ConclusionCreateParams {
#[must_use]
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
session_id: None,
}
}
}
pub(crate) struct ConclusionScopeInner {
http: HttpClient,
workspace_id: String,
#[allow(clippy::similar_names)]
observer: String,
#[allow(clippy::similar_names)]
observed: String,
}
#[derive(Clone)]
pub struct ConclusionScope {
inner: Arc<ConclusionScopeInner>,
}
impl ConclusionScope {
#[allow(clippy::similar_names)]
pub(crate) fn new(
http: HttpClient,
workspace_id: String,
observer_id: String,
observed_id: String,
) -> Self {
Self {
inner: Arc::new(ConclusionScopeInner {
http,
workspace_id,
observer: observer_id,
observed: observed_id,
}),
}
}
#[must_use]
pub fn observer_id(&self) -> &str {
&self.inner.observer
}
#[must_use]
pub fn observed_id(&self) -> &str {
&self.inner.observed
}
pub async fn create(
&self,
conclusions: impl IntoIterator<Item = impl Into<ConclusionCreateParams>>,
) -> Result<Vec<Conclusion>> {
let creates: Vec<ConclusionCreate> = conclusions
.into_iter()
.map(|c| {
let p: ConclusionCreateParams = c.into();
ConclusionCreate {
content: p.content,
observer_id: self.inner.observer.clone(),
observed_id: self.inner.observed.clone(),
session_id: p.session_id,
}
})
.collect();
if creates.is_empty() {
return Ok(Vec::new());
}
let route = routes::conclusions(&self.inner.workspace_id)?;
let all_data: Vec<ConclusionData> = if creates.len() <= 100 {
let body = ConclusionBatchCreate {
conclusions: creates,
};
self.inner.http.post(&route, Some(&body), &[]).await?
} else {
let mut all = Vec::with_capacity(creates.len());
for chunk in creates.chunks(100) {
let body = ConclusionBatchCreate {
conclusions: chunk.to_vec(),
};
let batch: Vec<ConclusionData> =
self.inner.http.post(&route, Some(&body), &[]).await?;
all.extend(batch);
}
all
};
Ok(all_data
.into_iter()
.map(|d| Conclusion::from_parts(self.inner.workspace_id.clone(), d))
.collect())
}
#[must_use]
pub fn representation(&self) -> ConclusionRepresentationBuilder {
ConclusionRepresentationBuilder {
http: self.inner.http.clone(),
workspace_id: self.inner.workspace_id.clone(),
observer_id: self.inner.observer.clone(),
observed_id: self.inner.observed.clone(),
search_query: None,
search_top_k: None,
search_max_distance: None,
include_most_frequent: None,
max_conclusions: None,
}
}
pub fn list(&self) -> ListConclusionsBuilder {
ListConclusionsBuilder {
scope: self.clone(),
page: 1,
size: 50,
session_id: None,
reverse: false,
}
}
pub fn query(&self, query: impl Into<String>) -> QueryConclusionsBuilder {
QueryConclusionsBuilder {
scope: self.clone(),
query: query.into(),
top_k: 10,
distance: None,
}
}
pub async fn delete(&self, conclusion_id: impl Into<String>) -> Result<()> {
let route = routes::conclusion_delete(&self.inner.workspace_id, &conclusion_id.into())?;
self.inner.http.delete(&route, &[]).await
}
}
pub struct ConclusionRepresentationBuilder {
http: HttpClient,
workspace_id: String,
observer_id: String,
observed_id: String,
search_query: Option<String>,
search_top_k: Option<u32>,
search_max_distance: Option<f64>,
include_most_frequent: Option<bool>,
max_conclusions: Option<u32>,
}
impl ConclusionRepresentationBuilder {
#[must_use]
pub fn search_query(mut self, val: impl Into<String>) -> Self {
self.search_query = Some(val.into());
self
}
#[must_use]
pub fn search_top_k(mut self, val: u32) -> Self {
self.search_top_k = Some(val);
self
}
#[must_use]
pub fn search_max_distance(mut self, val: f64) -> Self {
self.search_max_distance = Some(val);
self
}
#[must_use]
pub fn include_most_frequent(mut self, val: bool) -> Self {
self.include_most_frequent = Some(val);
self
}
#[must_use]
pub fn max_conclusions(mut self, val: u32) -> Self {
self.max_conclusions = Some(val);
self
}
pub async fn send(self) -> Result<String> {
validate_search_params(
self.search_top_k,
self.search_max_distance,
self.max_conclusions,
)?;
let params = crate::types::peer::PeerRepresentationGet {
session_id: None,
target: Some(self.observed_id),
search_query: self.search_query,
search_top_k: self.search_top_k,
search_max_distance: self.search_max_distance,
include_most_frequent: self.include_most_frequent,
max_conclusions: self.max_conclusions,
};
let route = routes::peer_representation(&self.workspace_id, &self.observer_id)?;
let resp: RepresentationResponse = self.http.post(&route, Some(¶ms), &[]).await?;
Ok(resp.representation)
}
}
#[must_use]
pub struct ListConclusionsBuilder {
scope: ConclusionScope,
page: u64,
size: u64,
session_id: Option<String>,
reverse: bool,
}
impl ListConclusionsBuilder {
pub fn page(mut self, page: u32) -> Self {
self.page = u64::from(page);
self
}
pub fn size(mut self, size: u32) -> Self {
self.size = u64::from(size);
self
}
pub fn session(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn reverse(mut self, reverse: bool) -> Self {
self.reverse = reverse;
self
}
pub async fn send(self) -> Result<ConclusionPage> {
let body = ConclusionGet::builder()
.filters(
ConclusionFilters::builder()
.observer_id(self.scope.inner.observer.clone())
.observed_id(self.scope.inner.observed.clone())
.maybe_session_id(self.session_id)
.build(),
)
.build();
let body = serde_json::to_value(&body).map_err(|e| HonchoError::Decode {
path: "ConclusionGet".to_owned(),
source: e,
})?;
let route = routes::conclusions_list(&self.scope.inner.workspace_id)?;
paginate_post(
&self.scope.inner.http,
&route,
Some(&body),
self.page,
self.size,
self.reverse,
)
.await
}
}
#[must_use]
pub struct QueryConclusionsBuilder {
scope: ConclusionScope,
query: String,
top_k: u32,
distance: Option<f64>,
}
impl QueryConclusionsBuilder {
pub fn top_k(mut self, top_k: u32) -> Self {
self.top_k = top_k;
self
}
pub fn distance(mut self, distance: f64) -> Self {
self.distance = Some(distance);
self
}
pub async fn send(self) -> Result<Vec<Conclusion>> {
if self.query.is_empty() {
return Err(HonchoError::Validation(
"query must not be empty".to_string(),
));
}
validate_search_params(Some(self.top_k), self.distance, None).map_err(|e| {
if let HonchoError::Validation(msg) = &e {
HonchoError::Validation(msg.replace("search_max_distance", "distance"))
} else {
e
}
})?;
let body = ConclusionQuery::builder()
.query(self.query)
.top_k(self.top_k)
.maybe_distance(self.distance)
.filters(
ConclusionFilters::builder()
.observer_id(self.scope.inner.observer.clone())
.observed_id(self.scope.inner.observed.clone())
.build(),
)
.build();
let body = serde_json::to_value(&body).map_err(|e| HonchoError::Decode {
path: "ConclusionQuery".to_owned(),
source: e,
})?;
let route = routes::conclusions_query(&self.scope.inner.workspace_id)?;
let data: Vec<ConclusionData> =
self.scope.inner.http.post(&route, Some(&body), &[]).await?;
let ws = self.scope.inner.workspace_id.clone();
Ok(data
.into_iter()
.map(|d| Conclusion::from_parts(ws.clone(), d))
.collect())
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unnecessary_wraps,
clippy::needless_pass_by_value,
clippy::unused_async
)]
mod tests {
use super::*;
use crate::http::client::HttpClient;
use wiremock::matchers::{body_json, body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_scope(server: &MockServer) -> ConclusionScope {
let http =
HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
ConclusionScope::new(http, "ws1".to_owned(), "alice".to_owned(), "bob".to_owned())
}
fn conclusion_json(content: &str, id: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"content": content,
"observer_id": "alice",
"observed_id": "bob",
"session_id": null,
"created_at": "2025-01-15T10:30:00Z"
})
}
fn conclusion_json_with_session(
content: &str,
id: &str,
session_id: &str,
) -> serde_json::Value {
serde_json::json!({
"id": id,
"content": content,
"observer_id": "alice",
"observed_id": "bob",
"session_id": session_id,
"created_at": "2025-01-15T10:30:00Z"
})
}
#[test]
fn create_params_minimal_serializes_content_only() {
let params = ConclusionCreateParams::new("hello");
let json = serde_json::to_value(params).unwrap();
assert_eq!(json["content"], "hello");
assert!(json.get("session_id").is_none());
}
#[test]
fn create_params_with_session_id_serializes_both() {
let params = ConclusionCreateParams::builder()
.content("world".to_owned())
.session_id("s1".to_owned())
.build();
let json = serde_json::to_value(params).unwrap();
assert_eq!(json["content"], "world");
assert_eq!(json["session_id"], "s1");
}
#[test]
fn debug_truncates_long_content() {
let data = make_conclusion_data("a".repeat(80), None);
let conc = Conclusion::from_parts("ws".to_owned(), data);
let dbg = format!("{conc:?}");
assert!(dbg.contains("Conclusion { id: \"c1\", content: \""));
assert!(!dbg.contains(&"a".repeat(80)));
}
#[test]
fn debug_truncation_multibyte_utf8() {
let data = make_conclusion_data("\u{4e00}".repeat(60), None);
let conc = Conclusion::from_parts("ws".to_owned(), data);
let dbg = format!("{conc:?}");
assert!(!dbg.contains(&"\u{4e00}".repeat(60)));
}
#[test]
fn display_returns_full_content() {
let long = "x".repeat(200);
let data = make_conclusion_data(long.clone(), None);
let conc = Conclusion::from_parts("ws".to_owned(), data);
assert_eq!(format!("{conc}"), long);
}
#[test]
fn getters_return_correct_values() {
let data = make_conclusion_data("content here".to_owned(), Some("sess-1".to_owned()));
let conc = Conclusion::from_parts("ws-1".to_owned(), data);
assert_eq!(conc.id(), "c1");
assert_eq!(conc.content(), "content here");
assert_eq!(conc.observer_id(), "obs");
assert_eq!(conc.observed_id(), "obd");
assert_eq!(conc.session_id(), Some("sess-1"));
assert_eq!(conc.workspace_id(), "ws-1");
}
fn make_conclusion_data(content: String, session_id: Option<String>) -> ConclusionData {
ConclusionData {
id: "c1".to_owned(),
content,
observer_id: "obs".to_owned(),
observed_id: "obd".to_owned(),
session_id,
created_at: chrono::Utc::now(),
}
}
fn test_http() -> HttpClient {
HttpClient::from_params(
HttpClient::builder()
.base_url("http://localhost".to_owned())
.build(),
)
.unwrap()
}
#[test]
fn conclusion_scope_new_self_scoped() {
let scope = ConclusionScope::new(
test_http(),
"ws".to_owned(),
"p1".to_owned(),
"p1".to_owned(),
);
assert_eq!(scope.observer_id(), "p1");
assert_eq!(scope.observed_id(), "p1");
}
#[test]
fn conclusion_scope_with_different_target() {
let scope = ConclusionScope::new(
test_http(),
"ws".to_owned(),
"alice".to_owned(),
"bob".to_owned(),
);
assert_eq!(scope.observer_id(), "alice");
assert_eq!(scope.observed_id(), "bob");
}
#[test]
fn conclusion_scope_clone_is_cheap() {
let scope =
ConclusionScope::new(test_http(), "ws".to_owned(), "a".to_owned(), "b".to_owned());
let clone = scope.clone();
assert_eq!(Arc::strong_count(&scope.inner), 2);
assert_eq!(clone.observer_id(), "a");
assert_eq!(clone.observed_id(), "b");
drop(clone);
assert_eq!(Arc::strong_count(&scope.inner), 1);
}
#[test]
fn conclusion_scope_construction_basic() {
let scope = ConclusionScope::new(
test_http(),
"ws-99".to_owned(),
"observer".to_owned(),
"observed".to_owned(),
);
assert_eq!(scope.observer_id(), "observer");
assert_eq!(scope.observed_id(), "observed");
}
#[tokio::test]
async fn create_single_conclusion() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"conclusions": [{
"content": "likes rust",
"observer_id": "alice",
"observed_id": "bob",
}]
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions"))
.and(body_json(&expected_body))
.respond_with(
ResponseTemplate::new(200).set_body_json(vec![conclusion_json("likes rust", "c1")]),
)
.mount(&server)
.await;
let results = scope
.create([ConclusionCreateParams::new("likes rust")])
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id(), "c1");
assert_eq!(results[0].content(), "likes rust");
}
#[tokio::test]
async fn create_injects_observer_and_observed() {
let server = MockServer::start().await;
let scope = make_scope(&server);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions"))
.and(body_string_contains("\"observer_id\":\"alice\""))
.and(body_string_contains("\"observed_id\":\"bob\""))
.respond_with(
ResponseTemplate::new(200).set_body_json(vec![conclusion_json("test", "c1")]),
)
.mount(&server)
.await;
let results = scope
.create([ConclusionCreateParams::new("test")])
.await
.unwrap();
assert_eq!(results[0].observer_id(), "alice");
assert_eq!(results[0].observed_id(), "bob");
}
#[tokio::test]
async fn create_batch_under_100_one_request() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let responses: Vec<serde_json::Value> = (0..50)
.map(|i| conclusion_json(&format!("conc-{i}"), &format!("id-{i}")))
.collect();
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions"))
.respond_with(ResponseTemplate::new(200).set_body_json(&responses))
.expect(1)
.mount(&server)
.await;
let params: Vec<ConclusionCreateParams> = (0..50)
.map(|i| ConclusionCreateParams::new(format!("conc-{i}")))
.collect();
let results = scope.create(params).await.unwrap();
assert_eq!(results.len(), 50);
}
#[tokio::test]
async fn create_batch_over_100_chunks_requests() {
let server = MockServer::start().await;
let scope = make_scope(&server);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions"))
.respond_with(|req: &wiremock::Request| {
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
let count = body["conclusions"].as_array().unwrap().len();
let items: Vec<serde_json::Value> = (0..count)
.map(|i| conclusion_json(&format!("c-{i}"), &format!("id-{i}")))
.collect();
ResponseTemplate::new(200).set_body_json(&items)
})
.expect(2)
.mount(&server)
.await;
let params: Vec<ConclusionCreateParams> = (0..150)
.map(|i| ConclusionCreateParams::new(format!("c-{i}")))
.collect();
let results = scope.create(params).await.unwrap();
assert_eq!(results.len(), 150);
}
#[tokio::test]
async fn representation_uses_peer_endpoint() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"target": "bob",
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/alice/representation"))
.and(body_json(&expected_body))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"representation": "Bob is friendly"})),
)
.mount(&server)
.await;
let rep = scope.representation().send().await.unwrap();
assert_eq!(rep, "Bob is friendly");
}
#[tokio::test]
async fn representation_with_search_options() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"target": "bob",
"search_query": "preferences",
"search_top_k": 5,
"search_max_distance": 0.8,
"include_most_frequent": true,
"max_conclusions": 20,
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/alice/representation"))
.and(body_json(&expected_body))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"representation": "curated rep"})),
)
.mount(&server)
.await;
let rep = scope
.representation()
.search_query("preferences")
.search_top_k(5)
.search_max_distance(0.8)
.include_most_frequent(true)
.max_conclusions(20)
.send()
.await
.unwrap();
assert_eq!(rep, "curated rep");
}
fn page_json(items: Vec<serde_json::Value>) -> serde_json::Value {
serde_json::json!({
"items": items,
"total": items.len(),
"page": 1,
"size": 50,
"pages": 1,
})
}
#[tokio::test]
async fn list_sends_correct_filters() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"filters": {
"observer_id": "alice",
"observed_id": "bob",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/list"))
.and(body_json(&expected_body))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(page_json(vec![conclusion_json("likes tea", "c1")])),
)
.mount(&server)
.await;
let page = scope.list().send().await.unwrap();
assert_eq!(page.total(), 1);
}
#[tokio::test]
async fn list_with_session_filter() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"filters": {
"observer_id": "alice",
"observed_id": "bob",
"session_id": "sess-42",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/list"))
.and(body_json(&expected_body))
.respond_with(ResponseTemplate::new(200).set_body_json(page_json(vec![
conclusion_json_with_session("scoped", "c2", "sess-42"),
])))
.mount(&server)
.await;
let page = scope.list().session("sess-42").send().await.unwrap();
assert_eq!(page.total(), 1);
}
#[tokio::test]
async fn list_with_reverse() {
let server = MockServer::start().await;
let scope = make_scope(&server);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/list"))
.and(wiremock::matchers::query_param("reverse", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_json(vec![])))
.mount(&server)
.await;
let page = scope.list().reverse(true).send().await.unwrap();
assert_eq!(page.total(), 0);
}
#[tokio::test]
async fn list_default_page_size() {
let server = MockServer::start().await;
let scope = make_scope(&server);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/list"))
.and(wiremock::matchers::query_param("page", "1"))
.and(wiremock::matchers::query_param("size", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_json(vec![])))
.mount(&server)
.await;
let page = scope.list().send().await.unwrap();
assert_eq!(page.page(), 1);
assert_eq!(page.size(), 50);
}
#[tokio::test]
async fn query_returns_conclusions() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"query": "preferences",
"filters": {
"observer_id": "alice",
"observed_id": "bob",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/query"))
.and(body_json(&expected_body))
.respond_with(ResponseTemplate::new(200).set_body_json(vec![
conclusion_json("likes rust", "c1"),
conclusion_json("prefers dark mode", "c2"),
]))
.mount(&server)
.await;
let results = scope.query("preferences").send().await.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].id(), "c1");
assert_eq!(results[1].id(), "c2");
}
#[tokio::test]
async fn query_with_top_k_and_distance() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let expected_body = serde_json::json!({
"query": "test",
"top_k": 5,
"distance": 0.7,
"filters": {
"observer_id": "alice",
"observed_id": "bob",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/query"))
.and(body_json(&expected_body))
.respond_with(
ResponseTemplate::new(200).set_body_json(vec![conclusion_json("result", "c1")]),
)
.mount(&server)
.await;
let results = scope
.query("test")
.top_k(5)
.distance(0.7)
.send()
.await
.unwrap();
assert_eq!(results.len(), 1);
}
#[tokio::test]
async fn query_validates_empty_query() {
let scope =
ConclusionScope::new(test_http(), "ws".to_owned(), "a".to_owned(), "b".to_owned());
let err = scope.query("").send().await.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
assert_eq!(err.code(), "validation_error");
}
#[tokio::test]
async fn query_validates_top_k_range() {
let scope =
ConclusionScope::new(test_http(), "ws".to_owned(), "a".to_owned(), "b".to_owned());
let err = scope.query("test").top_k(0).send().await.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
assert_eq!(err.code(), "validation_error");
let err = scope.query("test").top_k(101).send().await.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
}
#[tokio::test]
async fn query_validates_distance_range() {
let scope =
ConclusionScope::new(test_http(), "ws".to_owned(), "a".to_owned(), "b".to_owned());
let err = scope.query("test").distance(-0.1).send().await.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
let err = scope.query("test").distance(1.1).send().await.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
}
#[tokio::test]
async fn delete_calls_endpoint() {
let server = MockServer::start().await;
let scope = make_scope(&server);
Mock::given(method("DELETE"))
.and(path("/v3/workspaces/ws1/conclusions/conc-42"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
scope.delete("conc-42").await.unwrap();
}
#[tokio::test]
async fn full_lifecycle_create_list_query_delete() {
let server = MockServer::start().await;
let scope = make_scope(&server);
let create_body = serde_json::json!({
"conclusions": [{
"content": "likes rust",
"observer_id": "alice",
"observed_id": "bob",
}]
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions"))
.and(body_json(&create_body))
.respond_with(
ResponseTemplate::new(200).set_body_json(vec![conclusion_json("likes rust", "c1")]),
)
.expect(1)
.mount(&server)
.await;
let created = scope
.create([ConclusionCreateParams::new("likes rust")])
.await
.unwrap();
assert_eq!(created.len(), 1);
assert_eq!(created[0].id(), "c1");
let list_body = serde_json::json!({
"filters": {
"observer_id": "alice",
"observed_id": "bob",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/list"))
.and(body_json(&list_body))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(page_json(vec![conclusion_json("likes rust", "c1")])),
)
.expect(1)
.mount(&server)
.await;
let page = scope.list().send().await.unwrap();
assert_eq!(page.total(), 1);
let query_body = serde_json::json!({
"query": "preferences",
"filters": {
"observer_id": "alice",
"observed_id": "bob",
}
});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/conclusions/query"))
.and(body_json(&query_body))
.respond_with(
ResponseTemplate::new(200).set_body_json(vec![conclusion_json("likes rust", "c1")]),
)
.expect(1)
.mount(&server)
.await;
let queried = scope.query("preferences").send().await.unwrap();
assert_eq!(queried.len(), 1);
assert_eq!(queried[0].id(), "c1");
Mock::given(method("DELETE"))
.and(path("/v3/workspaces/ws1/conclusions/c1"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
scope.delete("c1").await.unwrap();
}
}