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