1use axum::{
32 Json, Router,
33 body::Bytes,
34 extract::{DefaultBodyLimit, Path, Query, State},
35 http::{HeaderMap, StatusCode, header},
36 response::{IntoResponse, NoContent, Response},
37 routing::{delete, get, post, put},
38};
39use serde::{Deserialize, Serialize};
40use std::sync::{Arc, Mutex};
41
42use crate::ast::{Document, NodeId, Tag, Title};
43use crate::embedding::EmbeddingClient;
44use crate::org_meta;
45use crate::parser;
46use crate::storage;
47
48pub struct AppState {
50 pub conn: Mutex<rusqlite::Connection>,
52 pub embedding_client: Option<Arc<dyn EmbeddingClient>>,
57 pub embedding_model: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct NodeView {
66 pub id: NodeId,
67 pub title: Title,
68 pub tags: Vec<Tag>,
69 pub document: Document,
70 #[serde(rename = "createdAt")]
71 pub created_at: String,
72 #[serde(rename = "updatedAt")]
73 pub updated_at: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct NodeSummary {
79 pub id: NodeId,
80 pub title: Title,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CreateNodeRequest {
86 pub id: Option<String>,
87 pub document: Document,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct UpdateNodeRequest {
93 pub document: Document,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct NeighborEdge {
99 pub target: String,
100 #[serde(rename = "linkType")]
101 pub link_type: storage::LinkType,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct NeighborhoodResponse {
107 pub outgoing: Vec<NeighborEdge>,
108 pub incoming: Vec<NeighborEdge>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct RelinkResponse {
114 pub nodes: u64,
115 pub links: u64,
116}
117
118#[derive(Debug, Deserialize)]
120pub struct SearchQuery {
121 pub q: Option<String>,
122}
123
124#[derive(Debug, Deserialize, Default)]
126pub struct ListNodesQuery {
127 pub limit: Option<i64>,
128 pub offset: Option<i64>,
129}
130
131#[derive(Debug, Deserialize, Default)]
133pub struct CreateNodeQuery {
134 pub id: Option<String>,
135}
136
137pub const REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
145
146#[must_use]
148pub fn build_router(state: Arc<AppState>) -> Router {
149 Router::new()
150 .route("/nodes/{id}", get(get_node_handler))
151 .route("/nodes/{id}", put(put_node_handler))
152 .route("/nodes/{id}", delete(delete_node_handler))
153 .route("/nodes/{id}/neighbors", get(neighbors_handler))
154 .route("/nodes", get(list_nodes_handler))
155 .route("/nodes", post(post_node_handler))
156 .route("/search", get(search_handler))
157 .route("/tags/{tag}", get(tags_handler))
158 .route("/recent", get(recent_handler))
159 .route("/admin/relink", post(relink_handler))
160 .layer(DefaultBodyLimit::max(REQUEST_BODY_LIMIT_BYTES))
161 .with_state(state)
162}
163
164async fn get_node_handler(
167 State(state): State<Arc<AppState>>,
168 Path(id): Path<String>,
169 headers: HeaderMap,
170) -> Result<Response, AppError> {
171 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
172 let nf = storage::get_node_full(&conn, &id)
173 .map_err(|_| AppError::Internal)?
174 .ok_or(AppError::NotFound)?;
175 Ok(render_node_view(&headers, to_node_view(&id, &nf)))
176}
177
178async fn put_node_handler(
179 State(state): State<Arc<AppState>>,
180 Path(id): Path<String>,
181 headers: HeaderMap,
182 body: Bytes,
183) -> Result<Response, AppError> {
184 let req = parse_update_body(&headers, &body, &id)?;
185 let embedding = compute_doc_embedding(&state, &req.document, &id).await;
186 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
187 let ok = storage::update_node_with(
188 &conn,
189 &id,
190 &req.document,
191 embedding,
192 state.embedding_model.as_deref(),
193 )
194 .map_err(|_| AppError::Internal)?;
195 if !ok {
196 return Err(AppError::NotFound);
197 }
198 let nf = storage::get_node_full(&conn, &id)
199 .map_err(|_| AppError::Internal)?
200 .ok_or(AppError::Internal)?;
201 Ok(render_node_view(&headers, to_node_view(&id, &nf)))
202}
203
204async fn delete_node_handler(
205 State(state): State<Arc<AppState>>,
206 Path(id): Path<String>,
207) -> Result<NoContent, AppError> {
208 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
209 let ok = storage::delete_node(&conn, &id).map_err(|_| AppError::Internal)?;
210 if ok {
211 Ok(NoContent)
212 } else {
213 Err(AppError::NotFound)
214 }
215}
216
217async fn post_node_handler(
218 State(state): State<Arc<AppState>>,
219 Query(query): Query<CreateNodeQuery>,
220 headers: HeaderMap,
221 body: Bytes,
222) -> Result<Response, AppError> {
223 let req = parse_create_body(&headers, &body)?;
224 let nid = {
229 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
230 match query.id.or_else(|| req.id.clone()) {
231 Some(supplied) => {
232 if storage::get_node(&conn, &supplied)
233 .map_err(|_| AppError::Internal)?
234 .is_some()
235 {
236 return Err(AppError::Conflict);
237 }
238 supplied
239 }
240 None => uuid::Uuid::new_v4().to_string(),
241 }
242 };
243 let embedding = compute_doc_embedding(&state, &req.document, &nid).await;
244 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
245 storage::insert_node_with(
246 &conn,
247 &nid,
248 &req.document,
249 embedding,
250 state.embedding_model.as_deref(),
251 )
252 .map_err(|_| AppError::Internal)?;
253 let nf = storage::get_node_full(&conn, &nid)
254 .map_err(|_| AppError::Internal)?
255 .ok_or(AppError::Internal)?;
256 Ok(render_created_node_view(&headers, to_node_view(&nid, &nf)))
257}
258
259async fn neighbors_handler(
260 State(state): State<Arc<AppState>>,
261 Path(id): Path<String>,
262) -> Result<Json<NeighborhoodResponse>, AppError> {
263 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
264 let n = storage::get_neighborhood(&conn, &id).map_err(|_| AppError::Internal)?;
265 let outgoing = n
266 .outgoing
267 .into_iter()
268 .map(|(t, lt)| NeighborEdge {
269 target: t.0,
270 link_type: lt,
271 })
272 .collect();
273 let incoming = n
274 .incoming
275 .into_iter()
276 .map(|(t, lt)| NeighborEdge {
277 target: t.0,
278 link_type: lt,
279 })
280 .collect();
281 Ok(Json(NeighborhoodResponse { outgoing, incoming }))
282}
283
284async fn list_nodes_handler(
285 State(state): State<Arc<AppState>>,
286 Query(query): Query<ListNodesQuery>,
287) -> Result<Json<Vec<NodeSummary>>, AppError> {
288 let limit = query.limit.unwrap_or(100).clamp(1, 1000);
292 let offset = query.offset.unwrap_or(0).max(0);
293 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
294 let rows = storage::list_all_nodes(&conn, limit as usize, offset as usize)
295 .map_err(|_| AppError::Internal)?;
296 Ok(Json(
297 rows.into_iter()
298 .map(|(id, title)| NodeSummary { id, title })
299 .collect(),
300 ))
301}
302
303async fn search_handler(
304 State(state): State<Arc<AppState>>,
305 Query(query): Query<SearchQuery>,
306) -> Result<Json<Vec<NodeSummary>>, AppError> {
307 let q = query.q.unwrap_or_default();
308 if q.trim().is_empty() {
309 return Ok(Json(vec![]));
310 }
311 let query_embedding: Option<(Vec<f32>, &str)> = match (
314 state.embedding_client.as_ref(),
315 state.embedding_model.as_deref(),
316 ) {
317 (Some(client), Some(model)) => match client.embed(&q).await {
318 Ok(v) => Some((v, model)),
319 Err(err) => {
320 tracing::error!(error = %err, "kb /search: embedding query failed");
321 None
322 }
323 },
324 _ => None,
325 };
326 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
327 let ids: Vec<String> = storage::search_hybrid(&conn, &q, query_embedding)
328 .map_err(|_| AppError::Internal)?
329 .into_iter()
330 .map(|n| n.0)
331 .collect();
332 let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
333 Ok(Json(
334 titles
335 .into_iter()
336 .map(|(id, title)| NodeSummary {
337 id: NodeId(id),
338 title: Title(title),
339 })
340 .collect(),
341 ))
342}
343
344async fn tags_handler(
345 State(state): State<Arc<AppState>>,
346 Path(tag): Path<String>,
347) -> Result<Json<Vec<NodeSummary>>, AppError> {
348 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
349 let rows = storage::list_by_tag(&conn, &tag).map_err(|_| AppError::Internal)?;
350 let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
351 let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
352 Ok(Json(
353 titles
354 .into_iter()
355 .map(|(id, title)| NodeSummary {
356 id: NodeId(id),
357 title: Title(title),
358 })
359 .collect(),
360 ))
361}
362
363async fn recent_handler(
364 State(state): State<Arc<AppState>>,
365) -> Result<Json<Vec<NodeSummary>>, AppError> {
366 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
367 let rows = storage::list_recent(&conn, 50).map_err(|_| AppError::Internal)?;
368 let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
369 let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
370 Ok(Json(
371 titles
372 .into_iter()
373 .map(|(id, title)| NodeSummary {
374 id: NodeId(id),
375 title: Title(title),
376 })
377 .collect(),
378 ))
379}
380
381async fn relink_handler(
382 State(state): State<Arc<AppState>>,
383) -> Result<Json<RelinkResponse>, AppError> {
384 let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
385 let (nodes, links) = storage::relink_all(&conn).map_err(|_| AppError::Internal)?;
386 Ok(Json(RelinkResponse {
387 nodes: nodes as u64,
388 links: links as u64,
389 }))
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395enum WireFormat {
396 Json,
397 Org,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum RequestFormat {
405 Json,
406 Org,
407 Markdown,
408}
409
410const ORG_CONTENT_TYPE: &str = "text/org";
413
414fn negotiate_response(headers: &HeaderMap) -> WireFormat {
419 let Some(accept) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else {
420 return WireFormat::Json;
421 };
422 for entry in accept.split(',') {
423 let mime = entry.split(';').next().unwrap_or("").trim();
424 match mime {
425 "application/json" | "*/*" | "application/*" => return WireFormat::Json,
426 "text/org" | "text/plain" => return WireFormat::Org,
427 _ => {}
428 }
429 }
430 WireFormat::Json
431}
432
433fn request_format(headers: &HeaderMap) -> RequestFormat {
435 let Some(ct) = headers
436 .get(header::CONTENT_TYPE)
437 .and_then(|v| v.to_str().ok())
438 else {
439 return RequestFormat::Json;
440 };
441 let mime = ct.split(';').next().unwrap_or("").trim();
442 match mime {
443 "text/org" | "text/plain" => RequestFormat::Org,
444 "text/markdown" => RequestFormat::Markdown,
445 _ => RequestFormat::Json,
446 }
447}
448
449fn parse_markdown_body(body: &[u8]) -> Result<Document, AppError> {
454 let text = std::str::from_utf8(body)
455 .map_err(|e| AppError::BadRequest(format!("markdown body is not valid UTF-8: {e}")))?;
456 crate::markdown::markdown_to_document(text).map_err(|e| match e {
457 crate::markdown::MarkdownError::PandocNotFound => AppError::Internal,
460 other => AppError::BadRequest(format!("markdown conversion failure: {other}")),
461 })
462}
463
464fn parse_create_body(headers: &HeaderMap, body: &[u8]) -> Result<CreateNodeRequest, AppError> {
465 match request_format(headers) {
466 RequestFormat::Json => serde_json::from_slice::<CreateNodeRequest>(body)
467 .map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
468 RequestFormat::Org => {
469 let mut document = parse_org_body(body)?;
470 let _ = org_meta::hydrate(&mut document);
475 Ok(CreateNodeRequest { id: None, document })
476 }
477 RequestFormat::Markdown => {
478 let document = parse_markdown_body(body)?;
481 Ok(CreateNodeRequest { id: None, document })
482 }
483 }
484}
485
486fn parse_update_body(
487 headers: &HeaderMap,
488 body: &[u8],
489 node_id: &str,
490) -> Result<UpdateNodeRequest, AppError> {
491 match request_format(headers) {
492 RequestFormat::Json => serde_json::from_slice::<UpdateNodeRequest>(body)
493 .map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
494 RequestFormat::Org => {
495 let mut document = parse_org_body(body)?;
496 if let Some(drawer_id) = org_meta::hydrate(&mut document) {
500 if drawer_id != node_id {
501 return Err(AppError::BadRequest(format!(
502 "document :ID: {drawer_id} does not match target node id {node_id}"
503 )));
504 }
505 }
506 Ok(UpdateNodeRequest { document })
507 }
508 RequestFormat::Markdown => {
509 let document = parse_markdown_body(body)?;
510 Ok(UpdateNodeRequest { document })
511 }
512 }
513}
514
515fn parse_org_body(body: &[u8]) -> Result<Document, AppError> {
516 let text = std::str::from_utf8(body)
517 .map_err(|e| AppError::BadRequest(format!("org body is not valid UTF-8: {e}")))?;
518 parser::parse_document(text)
519 .map_err(|e| AppError::BadRequest(format!("org parse failure: {e}")))
520}
521
522fn render_node_view(headers: &HeaderMap, view: NodeView) -> Response {
523 match negotiate_response(headers) {
524 WireFormat::Json => Json(view).into_response(),
525 WireFormat::Org => render_org(&view),
526 }
527}
528
529fn render_created_node_view(headers: &HeaderMap, view: NodeView) -> Response {
530 match negotiate_response(headers) {
531 WireFormat::Json => (StatusCode::CREATED, Json(view)).into_response(),
532 WireFormat::Org => {
533 let body = org_meta::render_with_metadata(
534 view.id.as_str(),
535 &view.created_at,
536 &view.updated_at,
537 &view.document,
538 );
539 (
540 StatusCode::CREATED,
541 [(header::CONTENT_TYPE, ORG_CONTENT_TYPE)],
542 body,
543 )
544 .into_response()
545 }
546 }
547}
548
549fn render_org(view: &NodeView) -> Response {
550 let body = org_meta::render_with_metadata(
551 view.id.as_str(),
552 &view.created_at,
553 &view.updated_at,
554 &view.document,
555 );
556 ([(header::CONTENT_TYPE, ORG_CONTENT_TYPE)], body).into_response()
557}
558
559async fn compute_doc_embedding(
569 state: &Arc<AppState>,
570 document: &Document,
571 node_id: &str,
572) -> Option<Vec<f32>> {
573 let client = state.embedding_client.as_ref()?;
574 state.embedding_model.as_deref()?;
575 let title = storage::extract_title(document);
576 let body_text = storage::extract_body_text(document);
577 let payload = format!("{title}\n{body_text}");
578 match client.embed(&payload).await {
579 Ok(v) => Some(v),
580 Err(err) => {
581 tracing::error!(node_id, error = %err, "kb embed generation failed");
582 None
583 }
584 }
585}
586
587fn to_node_view(id: &str, nf: &storage::NodeFullData) -> NodeView {
588 NodeView {
589 id: NodeId(id.to_string()),
590 title: Title(nf.title.clone()),
591 tags: nf.tags.clone(),
592 document: nf.document.clone(),
593 created_at: nf.created_at.clone(),
594 updated_at: nf.updated_at.clone(),
595 }
596}
597
598enum AppError {
600 NotFound,
601 Conflict,
602 BadRequest(String),
603 Internal,
604}
605
606impl IntoResponse for AppError {
607 fn into_response(self) -> Response {
608 match self {
609 AppError::NotFound => (StatusCode::NOT_FOUND, "not found\n").into_response(),
610 AppError::Conflict => (StatusCode::CONFLICT, "id already exists\n").into_response(),
611 AppError::BadRequest(msg) => {
612 (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response()
613 }
614 AppError::Internal => {
615 (StatusCode::INTERNAL_SERVER_ERROR, "internal server error\n").into_response()
616 }
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::ast::*;
625 use crate::generator;
626 use crate::storage;
627 use axum::body::Body;
628 use axum::http::Request;
629 use rusqlite::Connection;
630 use tower::ServiceExt;
631
632 fn setup_state() -> Arc<AppState> {
633 let conn = Connection::open_in_memory().unwrap();
634 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
635 storage::init_db(&conn).unwrap();
636 Arc::new(AppState {
637 conn: Mutex::new(conn),
638 embedding_client: None,
639 embedding_model: None,
640 })
641 }
642
643 fn sample_doc() -> Document {
644 Document {
645 blocks: vec![Block::Heading {
646 level: 1,
647 title: Title("Test".into()),
648 tags: vec![Tag("api".into())],
649 children: vec![Block::Paragraph {
650 inlines: vec![Inline::Plain("hello".into())],
651 }],
652 }],
653 }
654 }
655
656 fn link_doc(targets: &[&str]) -> Document {
657 let inlines: Vec<Inline> = targets
658 .iter()
659 .map(|t| Inline::Link {
660 target: format!("id:{t}"),
661 description: None,
662 })
663 .collect();
664 Document {
665 blocks: vec![Block::Paragraph { inlines }],
666 }
667 }
668
669 async fn send(
670 app: Router,
671 method: &str,
672 uri: &str,
673 content_type: Option<&str>,
674 accept: Option<&str>,
675 body: Vec<u8>,
676 ) -> (StatusCode, HeaderMap, Vec<u8>) {
677 let mut req = Request::builder().method(method).uri(uri);
678 if let Some(ct) = content_type {
679 req = req.header(header::CONTENT_TYPE, ct);
680 }
681 if let Some(a) = accept {
682 req = req.header(header::ACCEPT, a);
683 }
684 let resp = app
685 .oneshot(req.body(Body::from(body)).unwrap())
686 .await
687 .unwrap();
688 let status = resp.status();
689 let headers = resp.headers().clone();
690 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
691 .await
692 .unwrap()
693 .to_vec();
694 (status, headers, bytes)
695 }
696
697 fn json_str(bytes: &[u8]) -> serde_json::Value {
698 serde_json::from_slice(bytes).expect("response was not valid JSON")
699 }
700
701 #[test]
702 fn node_view_roundtrip_json() {
703 let view = NodeView {
704 id: NodeId("abc".into()),
705 title: Title("Test".into()),
706 tags: vec![Tag("api".into())],
707 document: sample_doc(),
708 created_at: "2026-01-01T00:00:00Z".into(),
709 updated_at: "2026-01-01T00:00:00Z".into(),
710 };
711 let json = serde_json::to_string(&view).unwrap();
712 let parsed: NodeView = serde_json::from_str(&json).unwrap();
713 assert_eq!(view, parsed);
714 }
715
716 #[test]
717 fn insert_and_get_node_full() {
718 let state = setup_state();
719 let conn = state.conn.lock().unwrap();
720 let nid = "test-1";
721 let doc = sample_doc();
722 storage::insert_node(&conn, nid, &doc).unwrap();
723 let nf = storage::get_node_full(&conn, nid).unwrap().unwrap();
724 assert_eq!(nf.title, "Test");
725 assert_eq!(nf.tags.len(), 1);
726 assert_eq!(nf.tags[0].0, "api");
727 assert_eq!(nf.document, doc);
728 }
729
730 #[test]
731 fn get_node_full_nonexistent() {
732 let state = setup_state();
733 let conn = state.conn.lock().unwrap();
734 let nf = storage::get_node_full(&conn, "no-such").unwrap();
735 assert!(nf.is_none());
736 }
737
738 #[test]
739 fn fetch_titles_works() {
740 let state = setup_state();
741 let conn = state.conn.lock().unwrap();
742 let doc = sample_doc();
743 storage::insert_node(&conn, "a", &doc).unwrap();
744 storage::insert_node(&conn, "b", &doc).unwrap();
745 let titles = storage::fetch_titles(&conn, &["a".into(), "b".into()]).unwrap();
746 assert_eq!(titles.len(), 2);
747 }
748
749 #[test]
750 fn search_fts_finds_results() {
751 let state = setup_state();
752 let conn = state.conn.lock().unwrap();
753 let doc = Document {
754 blocks: vec![Block::Heading {
755 level: 1,
756 title: Title("Rust Programming".into()),
757 tags: vec![],
758 children: vec![Block::Paragraph {
759 inlines: vec![Inline::Plain("systems programming language".into())],
760 }],
761 }],
762 };
763 storage::insert_node(&conn, "fts-1", &doc).unwrap();
764 let results = storage::search_fts(&conn, "programming").unwrap();
765 assert_eq!(results.len(), 1);
766 assert_eq!(results[0], "fts-1");
767 }
768
769 #[tokio::test]
772 async fn router_shape_listed_routes_respond() {
773 let state = setup_state();
774 {
776 let conn = state.conn.lock().unwrap();
777 storage::insert_node(&conn, "x", &sample_doc()).unwrap();
778 }
779 let app = build_router(state);
780
781 let expectations: &[(&str, &str, &str, &[u16])] = &[
785 ("GET", "/nodes/x", "", &[200]),
786 ("PUT", "/nodes/x", r#"{"document":{"blocks":[]}}"#, &[200]),
787 ("DELETE", "/nodes/x", "", &[204]),
788 ("GET", "/nodes/x/neighbors", "", &[200]),
789 ("GET", "/nodes", "", &[200]),
790 (
791 "POST",
792 "/nodes",
793 r#"{"document":{"blocks":[]}}"#,
794 &[201, 409],
795 ),
796 ("GET", "/search?q=hi", "", &[200]),
797 ("GET", "/tags/api", "", &[200]),
798 ("GET", "/recent", "", &[200]),
799 ("POST", "/admin/relink", "", &[200]),
800 ];
801 for (method, uri, body, ok) in expectations {
802 let (status, _, _) = send(
803 app.clone(),
804 method,
805 uri,
806 if body.is_empty() {
807 None
808 } else {
809 Some("application/json")
810 },
811 Some("application/json"),
812 body.as_bytes().to_vec(),
813 )
814 .await;
815 assert!(
816 ok.contains(&status.as_u16()),
817 "{method} {uri} got {status}, expected one of {ok:?}"
818 );
819 }
820 }
821
822 #[tokio::test]
823 async fn router_shape_rejects_unknown_paths() {
824 let app = build_router(setup_state());
825 let (status, _, _) = send(app, "GET", "/no-such-route", None, None, vec![]).await;
826 assert_eq!(status, StatusCode::NOT_FOUND);
827 }
828
829 #[tokio::test]
830 async fn router_shape_rejects_wrong_method_on_admin_relink() {
831 let app = build_router(setup_state());
833 let (status, _, _) = send(app, "GET", "/admin/relink", None, None, vec![]).await;
834 assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
835 }
836
837 #[tokio::test]
838 async fn router_shape_rejects_post_on_recent() {
839 let app = build_router(setup_state());
841 let (status, _, _) = send(app, "POST", "/recent", None, None, vec![]).await;
842 assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
843 }
844
845 #[tokio::test]
848 async fn api_neighbors_empty_for_isolated_node() {
849 let state = setup_state();
850 {
851 let conn = state.conn.lock().unwrap();
852 storage::insert_node(&conn, "lonely", &sample_doc()).unwrap();
853 }
854 let app = build_router(state);
855 let (status, _, body) =
856 send(app, "GET", "/nodes/lonely/neighbors", None, None, vec![]).await;
857 assert_eq!(status, StatusCode::OK);
858 let v = json_str(&body);
859 assert_eq!(v["outgoing"].as_array().unwrap().len(), 0);
860 assert_eq!(v["incoming"].as_array().unwrap().len(), 0);
861 }
862
863 #[tokio::test]
864 async fn api_neighbors_returns_200_with_empty_for_unknown_id() {
865 let app = build_router(setup_state());
867 let (status, _, body) = send(app, "GET", "/nodes/nope/neighbors", None, None, vec![]).await;
868 assert_eq!(status, StatusCode::OK);
869 let v = json_str(&body);
870 assert!(v["outgoing"].as_array().unwrap().is_empty());
871 assert!(v["incoming"].as_array().unwrap().is_empty());
872 }
873
874 #[tokio::test]
875 async fn api_neighbors_field_names_are_camelcase() {
876 let state = setup_state();
877 {
878 let conn = state.conn.lock().unwrap();
879 storage::insert_node(&conn, "a", &sample_doc()).unwrap();
880 storage::insert_node(&conn, "b", &sample_doc()).unwrap();
881 storage::insert_node(&conn, "c", &sample_doc()).unwrap();
882 storage::relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
884 storage::relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
885 }
886 let app = build_router(state);
887 let (status, _, body) = send(app, "GET", "/nodes/a/neighbors", None, None, vec![]).await;
888 assert_eq!(status, StatusCode::OK);
889 let v = json_str(&body);
890 let outgoing = v["outgoing"].as_array().unwrap();
891 let incoming = v["incoming"].as_array().unwrap();
892 assert_eq!(outgoing.len(), 1);
893 assert_eq!(incoming.len(), 1);
894 assert_eq!(outgoing[0]["target"].as_str().unwrap(), "b");
895 assert_eq!(outgoing[0]["linkType"].as_str().unwrap(), "id");
896 assert_eq!(incoming[0]["target"].as_str().unwrap(), "c");
897 assert_eq!(incoming[0]["linkType"].as_str().unwrap(), "id");
898 assert!(outgoing[0].get("link_type").is_none());
900 }
901
902 #[tokio::test]
905 async fn api_list_all_orders_by_updated_at_desc() {
906 let state = setup_state();
907 {
908 let conn = state.conn.lock().unwrap();
909 storage::insert_node(&conn, "first", &sample_doc()).unwrap();
910 std::thread::sleep(std::time::Duration::from_millis(10));
911 storage::insert_node(&conn, "second", &sample_doc()).unwrap();
912 std::thread::sleep(std::time::Duration::from_millis(10));
913 storage::insert_node(&conn, "third", &sample_doc()).unwrap();
914 }
915 let app = build_router(state);
916 let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
917 assert_eq!(status, StatusCode::OK);
918 let v = json_str(&body);
919 let arr = v.as_array().unwrap();
920 assert_eq!(arr.len(), 3);
921 assert_eq!(arr[0]["id"].as_str().unwrap(), "third");
922 assert_eq!(arr[1]["id"].as_str().unwrap(), "second");
923 assert_eq!(arr[2]["id"].as_str().unwrap(), "first");
924 }
925
926 #[tokio::test]
927 async fn api_list_all_paginates_with_limit_and_offset() {
928 let state = setup_state();
929 {
930 let conn = state.conn.lock().unwrap();
931 for i in 0..5 {
932 storage::insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
933 std::thread::sleep(std::time::Duration::from_millis(2));
934 }
935 }
936 let app = build_router(state);
937 let (status, _, body) = send(
938 app.clone(),
939 "GET",
940 "/nodes?limit=2&offset=0",
941 None,
942 None,
943 vec![],
944 )
945 .await;
946 assert_eq!(status, StatusCode::OK);
947 let v = json_str(&body);
948 assert_eq!(v.as_array().unwrap().len(), 2);
949
950 let (_, _, body2) = send(app, "GET", "/nodes?limit=2&offset=2", None, None, vec![]).await;
951 let v2 = json_str(&body2);
952 assert_eq!(v2.as_array().unwrap().len(), 2);
953 let p1: Vec<&str> = v
955 .as_array()
956 .unwrap()
957 .iter()
958 .map(|n| n["id"].as_str().unwrap())
959 .collect();
960 let p2: Vec<&str> = v2
961 .as_array()
962 .unwrap()
963 .iter()
964 .map(|n| n["id"].as_str().unwrap())
965 .collect();
966 for id in &p1 {
967 assert!(!p2.contains(id));
968 }
969 }
970
971 #[tokio::test]
972 async fn api_list_all_offset_past_end_returns_empty() {
973 let state = setup_state();
974 {
975 let conn = state.conn.lock().unwrap();
976 storage::insert_node(&conn, "only", &sample_doc()).unwrap();
977 }
978 let app = build_router(state);
979 let (status, _, body) = send(app, "GET", "/nodes?offset=999", None, None, vec![]).await;
980 assert_eq!(status, StatusCode::OK);
981 let v = json_str(&body);
982 assert!(v.as_array().unwrap().is_empty());
983 }
984
985 #[tokio::test]
986 async fn api_list_all_default_limit_is_100() {
987 let state = setup_state();
990 {
991 let conn = state.conn.lock().unwrap();
992 for i in 0..150 {
993 storage::insert_node(&conn, &format!("n{i:03}"), &sample_doc()).unwrap();
994 }
995 }
996 let app = build_router(state);
997 let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
998 assert_eq!(status, StatusCode::OK);
999 let v = json_str(&body);
1000 assert_eq!(v.as_array().unwrap().len(), 100);
1001 }
1002
1003 #[tokio::test]
1006 async fn api_relink_returns_node_and_link_counts() {
1007 let state = setup_state();
1008 {
1009 let conn = state.conn.lock().unwrap();
1010 storage::insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
1012 storage::insert_node(&conn, "b", &sample_doc()).unwrap();
1013 }
1014 let app = build_router(state);
1015 let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
1016 assert_eq!(status, StatusCode::OK);
1017 let v = json_str(&body);
1018 assert_eq!(v["nodes"].as_u64().unwrap(), 2);
1020 assert_eq!(v["links"].as_u64().unwrap(), 1);
1021 assert!(v.get("nodes_processed").is_none());
1023 assert!(v.get("links_written").is_none());
1024 }
1025
1026 #[tokio::test]
1027 async fn api_relink_works_on_empty_db() {
1028 let app = build_router(setup_state());
1029 let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
1030 assert_eq!(status, StatusCode::OK);
1031 let v = json_str(&body);
1032 assert_eq!(v["nodes"].as_u64().unwrap(), 0);
1033 assert_eq!(v["links"].as_u64().unwrap(), 0);
1034 }
1035
1036 #[tokio::test]
1039 async fn api_create_id_query_uses_query_id_when_only_query_provided() {
1040 let app = build_router(setup_state());
1041 let body = serde_json::to_vec(&CreateNodeRequest {
1042 id: None,
1043 document: sample_doc(),
1044 })
1045 .unwrap();
1046 let (status, _, resp) = send(
1047 app,
1048 "POST",
1049 "/nodes?id=from-query",
1050 Some("application/json"),
1051 Some("application/json"),
1052 body,
1053 )
1054 .await;
1055 assert_eq!(status, StatusCode::CREATED);
1056 let v = json_str(&resp);
1057 assert_eq!(v["id"].as_str().unwrap(), "from-query");
1058 }
1059
1060 #[tokio::test]
1061 async fn api_create_id_query_uses_body_id_when_no_query() {
1062 let app = build_router(setup_state());
1063 let body = serde_json::to_vec(&CreateNodeRequest {
1064 id: Some("from-body".into()),
1065 document: sample_doc(),
1066 })
1067 .unwrap();
1068 let (status, _, resp) = send(
1069 app,
1070 "POST",
1071 "/nodes",
1072 Some("application/json"),
1073 Some("application/json"),
1074 body,
1075 )
1076 .await;
1077 assert_eq!(status, StatusCode::CREATED);
1078 let v = json_str(&resp);
1079 assert_eq!(v["id"].as_str().unwrap(), "from-body");
1080 }
1081
1082 #[tokio::test]
1083 async fn api_create_id_query_query_overrides_body() {
1084 let app = build_router(setup_state());
1086 let body = serde_json::to_vec(&CreateNodeRequest {
1087 id: Some("from-body".into()),
1088 document: sample_doc(),
1089 })
1090 .unwrap();
1091 let (status, _, resp) = send(
1092 app,
1093 "POST",
1094 "/nodes?id=from-query",
1095 Some("application/json"),
1096 Some("application/json"),
1097 body,
1098 )
1099 .await;
1100 assert_eq!(status, StatusCode::CREATED);
1101 let v = json_str(&resp);
1102 assert_eq!(v["id"].as_str().unwrap(), "from-query");
1103 }
1104
1105 #[tokio::test]
1106 async fn api_create_id_query_mints_uuid_when_neither_supplied() {
1107 let app = build_router(setup_state());
1108 let body = serde_json::to_vec(&CreateNodeRequest {
1109 id: None,
1110 document: sample_doc(),
1111 })
1112 .unwrap();
1113 let (status, _, resp) = send(
1114 app,
1115 "POST",
1116 "/nodes",
1117 Some("application/json"),
1118 Some("application/json"),
1119 body,
1120 )
1121 .await;
1122 assert_eq!(status, StatusCode::CREATED);
1123 let v = json_str(&resp);
1124 let id = v["id"].as_str().unwrap();
1125 assert!(uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got {id}");
1126 }
1127
1128 #[tokio::test]
1129 async fn api_create_id_query_conflict_via_query_returns_409() {
1130 let state = setup_state();
1131 {
1132 let conn = state.conn.lock().unwrap();
1133 storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
1134 }
1135 let app = build_router(state);
1136 let body = serde_json::to_vec(&CreateNodeRequest {
1137 id: None,
1138 document: sample_doc(),
1139 })
1140 .unwrap();
1141 let (status, _, _) = send(
1142 app,
1143 "POST",
1144 "/nodes?id=taken",
1145 Some("application/json"),
1146 Some("application/json"),
1147 body,
1148 )
1149 .await;
1150 assert_eq!(status, StatusCode::CONFLICT);
1151 }
1152
1153 #[tokio::test]
1154 async fn api_create_id_query_conflict_via_body_returns_409() {
1155 let state = setup_state();
1156 {
1157 let conn = state.conn.lock().unwrap();
1158 storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
1159 }
1160 let app = build_router(state);
1161 let body = serde_json::to_vec(&CreateNodeRequest {
1162 id: Some("taken".into()),
1163 document: sample_doc(),
1164 })
1165 .unwrap();
1166 let (status, _, _) = send(
1167 app,
1168 "POST",
1169 "/nodes",
1170 Some("application/json"),
1171 Some("application/json"),
1172 body,
1173 )
1174 .await;
1175 assert_eq!(status, StatusCode::CONFLICT);
1176 }
1177
1178 #[tokio::test]
1181 async fn api_orgtext_read_returns_org_for_text_org_accept() {
1182 let state = setup_state();
1183 {
1184 let conn = state.conn.lock().unwrap();
1185 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1186 }
1187 let app = build_router(state);
1188 let (status, headers, body) =
1189 send(app, "GET", "/nodes/n", None, Some("text/org"), vec![]).await;
1190 assert_eq!(status, StatusCode::OK);
1191 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1192 assert!(ct.starts_with("text/org"), "content-type was {ct}");
1193 let text = String::from_utf8(body).unwrap();
1194 assert!(
1197 text.starts_with(":PROPERTIES:\n:ID: n\n"),
1198 "missing metadata drawer: {text}"
1199 );
1200 assert!(text.ends_with(&generator::generate(&sample_doc())));
1201 assert!(!text.starts_with('{'), "expected org body, got JSON-ish");
1202 }
1203
1204 #[tokio::test]
1205 async fn api_orgtext_read_accepts_text_plain_as_synonym() {
1206 let state = setup_state();
1207 {
1208 let conn = state.conn.lock().unwrap();
1209 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1210 }
1211 let app = build_router(state);
1212 let (status, headers, body) =
1213 send(app, "GET", "/nodes/n", None, Some("text/plain"), vec![]).await;
1214 assert_eq!(status, StatusCode::OK);
1215 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1216 assert!(ct.starts_with("text/org"), "content-type was {ct}");
1217 let text = String::from_utf8(body).unwrap();
1218 assert!(text.starts_with(":PROPERTIES:\n:ID: n\n"));
1219 assert!(text.ends_with(&generator::generate(&sample_doc())));
1220 }
1221
1222 #[tokio::test]
1223 async fn api_orgtext_read_returns_json_when_accept_application_json() {
1224 let state = setup_state();
1225 {
1226 let conn = state.conn.lock().unwrap();
1227 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1228 }
1229 let app = build_router(state);
1230 let (status, headers, body) = send(
1231 app,
1232 "GET",
1233 "/nodes/n",
1234 None,
1235 Some("application/json"),
1236 vec![],
1237 )
1238 .await;
1239 assert_eq!(status, StatusCode::OK);
1240 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1241 assert!(ct.starts_with("application/json"), "content-type was {ct}");
1242 let v = json_str(&body);
1243 assert_eq!(v["id"].as_str().unwrap(), "n");
1244 }
1245
1246 #[tokio::test]
1247 async fn api_orgtext_read_defaults_to_json_when_accept_absent() {
1248 let state = setup_state();
1249 {
1250 let conn = state.conn.lock().unwrap();
1251 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1252 }
1253 let app = build_router(state);
1254 let (status, headers, body) = send(app, "GET", "/nodes/n", None, None, vec![]).await;
1255 assert_eq!(status, StatusCode::OK);
1256 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1257 assert!(ct.starts_with("application/json"));
1258 let _ = json_str(&body);
1259 }
1260
1261 #[tokio::test]
1262 async fn api_orgtext_read_unknown_id_404_under_org_accept() {
1263 let app = build_router(setup_state());
1264 let (status, _, _) = send(app, "GET", "/nodes/nope", None, Some("text/org"), vec![]).await;
1265 assert_eq!(status, StatusCode::NOT_FOUND);
1266 }
1267
1268 #[tokio::test]
1269 async fn api_orgtext_read_unknown_id_404_under_json_accept() {
1270 let app = build_router(setup_state());
1271 let (status, _, _) = send(
1272 app,
1273 "GET",
1274 "/nodes/nope",
1275 None,
1276 Some("application/json"),
1277 vec![],
1278 )
1279 .await;
1280 assert_eq!(status, StatusCode::NOT_FOUND);
1281 }
1282
1283 #[tokio::test]
1286 async fn api_orgtext_write_post_parses_org_body() {
1287 let app = build_router(setup_state());
1288 let body = b"* Hello\nworld\n".to_vec();
1289 let (status, _, resp) = send(
1290 app,
1291 "POST",
1292 "/nodes",
1293 Some("text/org"),
1294 Some("application/json"),
1295 body,
1296 )
1297 .await;
1298 assert_eq!(status, StatusCode::CREATED);
1299 let v = json_str(&resp);
1300 assert_eq!(v["title"].as_str().unwrap(), "Hello");
1302 let id = v["id"].as_str().unwrap();
1304 assert!(uuid::Uuid::parse_str(id).is_ok());
1305 }
1306
1307 fn pandoc_available() -> bool {
1309 std::process::Command::new("pandoc")
1310 .arg("--version")
1311 .output()
1312 .is_ok_and(|o| o.status.success())
1313 }
1314
1315 #[tokio::test]
1316 async fn api_markdown_write_post_converts_via_pandoc() {
1317 if !pandoc_available() {
1318 eprintln!("skipping: pandoc not on PATH");
1319 return;
1320 }
1321 let app = build_router(setup_state());
1322 let body = b"# Markdown Title\n\nsome ~~struck~~ body\n".to_vec();
1323 let (status, _, resp) = send(
1324 app,
1325 "POST",
1326 "/nodes",
1327 Some("text/markdown"),
1328 Some("application/json"),
1329 body,
1330 )
1331 .await;
1332 assert_eq!(status, StatusCode::CREATED);
1333 let v = json_str(&resp);
1334 assert_eq!(v["title"].as_str().unwrap(), "Markdown Title");
1335 }
1336
1337 #[tokio::test]
1338 async fn api_markdown_write_put_converts_via_pandoc() {
1339 if !pandoc_available() {
1340 eprintln!("skipping: pandoc not on PATH");
1341 return;
1342 }
1343 let state = setup_state();
1344 {
1345 let conn = state.conn.lock().unwrap();
1346 storage::insert_node(&conn, "md-target", &sample_doc()).unwrap();
1347 }
1348 let app = build_router(state);
1349 let (status, _, resp) = send(
1350 app,
1351 "PUT",
1352 "/nodes/md-target",
1353 Some("text/markdown"),
1354 Some("application/json"),
1355 b"# Replaced\n\nnew body\n".to_vec(),
1356 )
1357 .await;
1358 assert_eq!(status, StatusCode::OK);
1359 let v = json_str(&resp);
1360 assert_eq!(v["id"].as_str().unwrap(), "md-target");
1361 assert_eq!(v["title"].as_str().unwrap(), "Replaced");
1362 }
1363
1364 #[tokio::test]
1365 async fn api_orgtext_write_post_accepts_text_plain_synonym() {
1366 let app = build_router(setup_state());
1367 let body = b"* Plain\nbody\n".to_vec();
1368 let (status, _, resp) = send(
1369 app,
1370 "POST",
1371 "/nodes",
1372 Some("text/plain"),
1373 Some("application/json"),
1374 body,
1375 )
1376 .await;
1377 assert_eq!(status, StatusCode::CREATED);
1378 let v = json_str(&resp);
1379 assert_eq!(v["title"].as_str().unwrap(), "Plain");
1380 }
1381
1382 #[tokio::test]
1383 async fn api_orgtext_write_post_uses_query_id_for_org_body() {
1384 let app = build_router(setup_state());
1387 let body = b"* From org\n".to_vec();
1388 let (status, _, resp) = send(
1389 app,
1390 "POST",
1391 "/nodes?id=org-import-1",
1392 Some("text/org"),
1393 Some("application/json"),
1394 body,
1395 )
1396 .await;
1397 assert_eq!(status, StatusCode::CREATED);
1398 let v = json_str(&resp);
1399 assert_eq!(v["id"].as_str().unwrap(), "org-import-1");
1400 }
1401
1402 #[tokio::test]
1403 async fn api_orgtext_write_post_strips_kb_metadata_drawer() {
1404 let app = build_router(setup_state());
1407 let body = b":PROPERTIES:\n:ID: stale-id\n:CREATED: t0\n:END:\n* Heading\nbody\n".to_vec();
1408 let (status, _, resp) = send(
1409 app,
1410 "POST",
1411 "/nodes",
1412 Some("text/org"),
1413 Some("application/json"),
1414 body,
1415 )
1416 .await;
1417 assert_eq!(status, StatusCode::CREATED);
1418 let v = json_str(&resp);
1419 let id = v["id"].as_str().unwrap();
1421 assert!(uuid::Uuid::parse_str(id).is_ok());
1422 let blocks = v["document"]["blocks"].as_array().unwrap();
1424 assert_eq!(blocks.len(), 1);
1425 assert!(
1426 blocks[0].get("Heading").is_some(),
1427 "drawer leaked into AST: {v}"
1428 );
1429 }
1430
1431 #[tokio::test]
1432 async fn api_orgtext_write_put_400_on_id_mismatch() {
1433 let state = setup_state();
1436 {
1437 let conn = state.conn.lock().unwrap();
1438 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1439 }
1440 let app = build_router(state);
1441 let body = b":PROPERTIES:\n:ID: other-node\n:END:\n* Heading\n".to_vec();
1442 let (status, _, _) = send(
1443 app,
1444 "PUT",
1445 "/nodes/n",
1446 Some("text/org"),
1447 Some("application/json"),
1448 body,
1449 )
1450 .await;
1451 assert_eq!(status, StatusCode::BAD_REQUEST);
1452 }
1453
1454 #[tokio::test]
1455 async fn api_orgtext_write_put_accepts_matching_drawer_id() {
1456 let state = setup_state();
1457 {
1458 let conn = state.conn.lock().unwrap();
1459 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1460 }
1461 let app = build_router(state);
1462 let body = b":PROPERTIES:\n:ID: n\n:END:\n* Updated\n".to_vec();
1463 let (status, _, resp) = send(
1464 app,
1465 "PUT",
1466 "/nodes/n",
1467 Some("text/org"),
1468 Some("application/json"),
1469 body,
1470 )
1471 .await;
1472 assert_eq!(status, StatusCode::OK);
1473 let v = json_str(&resp);
1474 assert_eq!(v["title"].as_str().unwrap(), "Updated");
1475 let blocks = v["document"]["blocks"].as_array().unwrap();
1477 assert!(blocks[0].get("Heading").is_some(), "drawer leaked: {v}");
1478 }
1479
1480 #[tokio::test]
1481 async fn api_orgtext_write_put_parses_org_body() {
1482 let state = setup_state();
1483 {
1484 let conn = state.conn.lock().unwrap();
1485 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1486 }
1487 let app = build_router(state);
1488 let body = b"* Replaced\nbody\n".to_vec();
1489 let (status, _, resp) = send(
1490 app,
1491 "PUT",
1492 "/nodes/n",
1493 Some("text/org"),
1494 Some("application/json"),
1495 body,
1496 )
1497 .await;
1498 assert_eq!(status, StatusCode::OK);
1499 let v = json_str(&resp);
1500 assert_eq!(v["title"].as_str().unwrap(), "Replaced");
1501 }
1502
1503 #[tokio::test]
1504 async fn api_orgtext_write_put_400_on_invalid_utf8() {
1505 let state = setup_state();
1506 {
1507 let conn = state.conn.lock().unwrap();
1508 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1509 }
1510 let app = build_router(state);
1511 let body = vec![0xff, 0xfe, 0xfd];
1513 let (status, _, _) = send(
1514 app,
1515 "PUT",
1516 "/nodes/n",
1517 Some("text/org"),
1518 Some("application/json"),
1519 body,
1520 )
1521 .await;
1522 assert_eq!(status, StatusCode::BAD_REQUEST);
1523 }
1524
1525 #[tokio::test]
1526 async fn api_orgtext_write_post_400_on_invalid_utf8() {
1527 let app = build_router(setup_state());
1528 let body = vec![0xff, 0xfe, 0xfd];
1529 let (status, _, _) = send(
1530 app,
1531 "POST",
1532 "/nodes",
1533 Some("text/org"),
1534 Some("application/json"),
1535 body,
1536 )
1537 .await;
1538 assert_eq!(status, StatusCode::BAD_REQUEST);
1539 }
1540
1541 #[tokio::test]
1544 async fn api_orgtext_write_response_post_returns_org_under_org_accept() {
1545 let app = build_router(setup_state());
1546 let body = b"* Hello\nworld\n".to_vec();
1547 let (status, headers, resp) = send(
1548 app,
1549 "POST",
1550 "/nodes",
1551 Some("text/org"),
1552 Some("text/org"),
1553 body,
1554 )
1555 .await;
1556 assert_eq!(status, StatusCode::CREATED);
1557 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1558 assert!(ct.starts_with("text/org"), "content-type was {ct}");
1559 let text = String::from_utf8(resp).unwrap();
1560 assert!(!text.starts_with('{'));
1562 assert!(text.contains("Hello"));
1563 }
1564
1565 #[tokio::test]
1566 async fn api_orgtext_write_response_put_returns_org_under_org_accept() {
1567 let state = setup_state();
1568 {
1569 let conn = state.conn.lock().unwrap();
1570 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1571 }
1572 let app = build_router(state);
1573 let body = serde_json::to_vec(&UpdateNodeRequest {
1574 document: Document {
1575 blocks: vec![Block::Heading {
1576 level: 1,
1577 title: Title("PutOrg".into()),
1578 tags: vec![],
1579 children: vec![],
1580 }],
1581 },
1582 })
1583 .unwrap();
1584 let (status, headers, resp) = send(
1585 app,
1586 "PUT",
1587 "/nodes/n",
1588 Some("application/json"),
1589 Some("text/org"),
1590 body,
1591 )
1592 .await;
1593 assert_eq!(status, StatusCode::OK);
1594 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1595 assert!(ct.starts_with("text/org"), "content-type was {ct}");
1596 let text = String::from_utf8(resp).unwrap();
1597 assert!(!text.starts_with('{'));
1598 assert!(text.contains("PutOrg"));
1599 }
1600
1601 #[tokio::test]
1602 async fn api_orgtext_write_response_put_returns_json_under_json_accept() {
1603 let state = setup_state();
1604 {
1605 let conn = state.conn.lock().unwrap();
1606 storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1607 }
1608 let app = build_router(state);
1609 let body = b"* Title\n".to_vec();
1610 let (status, headers, resp) = send(
1611 app,
1612 "PUT",
1613 "/nodes/n",
1614 Some("text/org"),
1615 Some("application/json"),
1616 body,
1617 )
1618 .await;
1619 assert_eq!(status, StatusCode::OK);
1620 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1621 assert!(ct.starts_with("application/json"), "content-type was {ct}");
1622 let v = json_str(&resp);
1623 assert_eq!(v["title"].as_str().unwrap(), "Title");
1624 }
1625
1626 #[tokio::test]
1634 async fn search_endpoint_shape_returns_node_summary_array() {
1635 let state = setup_state();
1636 {
1637 let conn = state.conn.lock().unwrap();
1638 storage::insert_node(
1639 &conn,
1640 "match-1",
1641 &Document {
1642 blocks: vec![Block::Heading {
1643 level: 1,
1644 title: Title("Rust Programming".into()),
1645 tags: vec![],
1646 children: vec![Block::Paragraph {
1647 inlines: vec![Inline::Plain("systems language".into())],
1648 }],
1649 }],
1650 },
1651 )
1652 .unwrap();
1653 }
1654 let app = build_router(state);
1655 let (status, headers, body) = send(
1656 app,
1657 "GET",
1658 "/search?q=programming",
1659 None,
1660 Some("application/json"),
1661 vec![],
1662 )
1663 .await;
1664 assert_eq!(status, StatusCode::OK);
1665 let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1666 assert!(ct.starts_with("application/json"), "content-type was {ct}");
1667 let v = json_str(&body);
1668 let arr = v.as_array().expect("response is a JSON array");
1669 assert_eq!(arr.len(), 1);
1670 let item = &arr[0];
1672 let obj = item.as_object().expect("array entries are objects");
1673 let mut keys: Vec<&String> = obj.keys().collect();
1674 keys.sort();
1675 assert_eq!(
1676 keys,
1677 vec![&"id".to_string(), &"title".to_string()],
1678 "exactly id+title — no extra fields"
1679 );
1680 assert_eq!(item["id"].as_str().unwrap(), "match-1");
1681 assert_eq!(item["title"].as_str().unwrap(), "Rust Programming");
1682 }
1683
1684 #[tokio::test]
1685 async fn search_endpoint_shape_empty_query_returns_empty_array() {
1686 let app = build_router(setup_state());
1687 let (status, _, body) = send(
1688 app,
1689 "GET",
1690 "/search?q=",
1691 None,
1692 Some("application/json"),
1693 vec![],
1694 )
1695 .await;
1696 assert_eq!(status, StatusCode::OK);
1697 let v = json_str(&body);
1698 assert!(v.as_array().unwrap().is_empty());
1699 }
1700
1701 #[tokio::test]
1702 async fn search_endpoint_shape_no_q_param_returns_empty_array() {
1703 let app = build_router(setup_state());
1704 let (status, _, body) = send(
1705 app,
1706 "GET",
1707 "/search",
1708 None,
1709 Some("application/json"),
1710 vec![],
1711 )
1712 .await;
1713 assert_eq!(status, StatusCode::OK);
1714 let v = json_str(&body);
1715 assert!(v.as_array().unwrap().is_empty());
1716 }
1717}