Skip to main content

kb/
api.rs

1//! HTTP API type definition and handlers.
2//!
3//! Ported from Haskell `KB/API.hs` and `KB/API/Handlers.hs`.
4//! Uses axum instead of Servant.
5//!
6//! ## Endpoints
7//!
8//! - `GET    /nodes/:id`            → 200 NodeView | 404
9//! - `PUT    /nodes/:id`            → UpdateNodeRequest → 200 NodeView | 404
10//! - `DELETE /nodes/:id`            → 204 | 404
11//! - `GET    /nodes/:id/neighbors`  → 200 NeighborhoodResponse
12//! - `POST   /nodes?id=…`           → CreateNodeRequest → 201 NodeView | 409
13//! - `GET    /nodes?limit=&offset=` → 200 [NodeSummary]
14//! - `GET    /search?q=…`           → 200 [NodeSummary]
15//! - `GET    /tags/:tag`            → 200 [NodeSummary]
16//! - `GET    /recent`               → 200 [NodeSummary]
17//! - `POST   /admin/relink`         → 200 RelinkResponse
18//!
19//! ## Content negotiation
20//!
21//! Read/write endpoints honour `text/org` (with `text/plain` as a synonym)
22//! on both `Content-Type` (request) and `Accept` (response). An org
23//! request body is parsed via [`crate::parser`]; an org response renders
24//! the document via [`crate::generator`]. An org response carries the
25//! node id and timestamps in a leading `:PROPERTIES:` drawer (see
26//! [`crate::org_meta`]); a matching drawer on an org request body is
27//! stripped before storage. On `PUT` the drawer's `:ID:`, when present,
28//! must equal the path id (a mismatch is `400`); on `POST` it is
29//! discarded — callers wanting to set an id must use `?id=`.
30
31use 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
48/// Shared application state passed to all handlers.
49pub struct AppState {
50    /// SQLite connection wrapped in a sync mutex.
51    pub conn: Mutex<rusqlite::Connection>,
52    /// Optional async embedding client. When `Some`, write-path handlers
53    /// compute an embedding for each new/updated node and `/search`
54    /// runs hybrid (FTS + vector) ranking. `None` short-circuits to
55    /// FTS-only with no embeddings rows written.
56    pub embedding_client: Option<Arc<dyn EmbeddingClient>>,
57    /// Model name stamped into the `embeddings.model` column when a
58    /// client is configured. `None` is equivalent to no client (no
59    /// embedding row is written).
60    pub embedding_model: Option<String>,
61}
62
63/// Full node response: identity, derived metadata, the AST, and timestamps.
64#[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/// Compact node listing for search/tags/recent results.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct NodeSummary {
79    pub id: NodeId,
80    pub title: Title,
81}
82
83/// `POST /nodes` body.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CreateNodeRequest {
86    pub id: Option<String>,
87    pub document: Document,
88}
89
90/// `PUT /nodes/:id` body.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct UpdateNodeRequest {
93    pub document: Document,
94}
95
96/// One edge in a node's link neighborhood.
97#[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/// The link neighborhood of a node: outgoing and incoming edges.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct NeighborhoodResponse {
107    pub outgoing: Vec<NeighborEdge>,
108    pub incoming: Vec<NeighborEdge>,
109}
110
111/// The shape returned by `POST /admin/relink`.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct RelinkResponse {
114    pub nodes: u64,
115    pub links: u64,
116}
117
118/// `GET /search` query parameters.
119#[derive(Debug, Deserialize)]
120pub struct SearchQuery {
121    pub q: Option<String>,
122}
123
124/// `GET /nodes` query parameters.
125#[derive(Debug, Deserialize, Default)]
126pub struct ListNodesQuery {
127    pub limit: Option<i64>,
128    pub offset: Option<i64>,
129}
130
131/// `POST /nodes` query parameters.
132#[derive(Debug, Deserialize, Default)]
133pub struct CreateNodeQuery {
134    pub id: Option<String>,
135}
136
137/// Maximum request body size for write-path handlers.
138///
139/// Raised from the axum default of 2 MiB so real-world transcripts
140/// (long Claude Code sessions, large Slack/Discord exports) can be
141/// ingested without hitting `413 Payload Too Large`. The 32 MiB
142/// ceiling is fixed in this phase; bodies larger than this still
143/// return 413.
144pub const REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
145
146/// Build the full axum Router for the KB API.
147#[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
164// ── Handlers ────────────────────────────────────────────────────────────
165
166async 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    // Precedence: query ?id= wins over body.id wins over server-minted UUID.
225    // Resolve id and verify availability with a short-lived lock — drop
226    // the connection guard before .await so we never hold the mutex
227    // across the embedding HTTP call.
228    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    // Match `KB/API/Handlers.hs::listNodesHandler`:
289    //   limit = clamp 1 1000 (fromMaybe 100 mLimit)
290    //   offset = max 0 (fromMaybe 0 mOffset)
291    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    // Compute the query embedding asynchronously *before* taking the
312    // SQLite mutex so we never hold the lock across an HTTP await.
313    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// ── Content-type negotiation ────────────────────────────────────────────
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395enum WireFormat {
396    Json,
397    Org,
398}
399
400/// Request body formats. A superset of [`WireFormat`]: markdown is an
401/// ingest-only format (kb never renders markdown back out), so it has no
402/// place in response negotiation.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum RequestFormat {
405    Json,
406    Org,
407    Markdown,
408}
409
410/// Org content type emitted on responses. Matches Haskell `Accept OrgText`
411/// which lists `text/org` first.
412const ORG_CONTENT_TYPE: &str = "text/org";
413
414/// Pick a response format from the `Accept` header. Walks entries in
415/// order and returns the first match. Defaults to JSON when the header
416/// is absent or contains nothing recognised — matching Servant, which
417/// picks the first entry of `'[JSON, OrgText]` for `*/*`.
418fn 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
433/// Pick a request body format from `Content-Type`. Defaults to JSON.
434fn 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
449/// Convert a markdown request body into a [`Document`].
450///
451/// A missing `pandoc` binary is a server-side misconfiguration (500);
452/// any other conversion failure is attributed to the request body (400).
453fn 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        // A missing pandoc binary is a server-side misconfiguration (500);
458        // every input-shaped failure is attributed to the request body (400).
459        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            // Strip a round-tripped kb metadata drawer so it never reaches
471            // the stored body. text/org POSTs never carry a body id — the
472            // ?id= query param is the only client-supplied id channel, so
473            // the drawer's :ID: is discarded here.
474            let _ = org_meta::hydrate(&mut document);
475            Ok(CreateNodeRequest { id: None, document })
476        }
477        RequestFormat::Markdown => {
478            // Markdown bodies carry no kb metadata drawer; the ?id= query
479            // param remains the only client-supplied id channel.
480            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            // Strip the round-tripped kb metadata drawer. When it carries
497            // an :ID:, it must name the node being updated — a mismatch
498            // signals an exported file applied to the wrong node.
499            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
559// ── Helpers ─────────────────────────────────────────────────────────────
560
561/// Compute the embedding payload for a write-path handler.
562///
563/// Mirrors the v5 `embed_node` behaviour exactly: payload is
564/// `title + "\n" + body_text`, and an embedding-generation failure is
565/// logged via `tracing` but does not roll back the storage write — the
566/// handler proceeds with `None`. With no embedding client configured at
567/// startup, returns `None` without invoking any embedding code.
568async 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
598/// Application-level error responses.
599enum 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    // ── router_shape ──────────────────────────────────────────────────
770
771    #[tokio::test]
772    async fn router_shape_listed_routes_respond() {
773        let state = setup_state();
774        // Seed a node so /nodes/:id paths can resolve to 200.
775        {
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        // Each (method, uri, body, allowed_status) tuple must produce a
782        // status that is NEITHER 404 (route missing) NOR 405 (wrong
783        // method). Specific behaviour is covered in the other tests.
784        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        // /admin/relink is POST-only; GET must not match.
832        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        // /recent is GET-only.
840        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    // ── api_neighbors ─────────────────────────────────────────────────
846
847    #[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        // Per criterion: unknown id returns 200 with empty arrays, NOT 404.
866        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            // a -> b, c -> a
883            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        // No snake_case "link_type" field should appear.
899        assert!(outgoing[0].get("link_type").is_none());
900    }
901
902    // ── api_list_all ──────────────────────────────────────────────────
903
904    #[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        // No overlap between the two pages.
954        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        // Haskell default: limit = 100. Insert 150 rows; default page
988        // returns 100.
989        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    // ── api_relink ────────────────────────────────────────────────────
1004
1005    #[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            // a links to b before b exists; b inserted with no links.
1011            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        // JSON field names: nodes, links.
1019        assert_eq!(v["nodes"].as_u64().unwrap(), 2);
1020        assert_eq!(v["links"].as_u64().unwrap(), 1);
1021        // No surprise extra fields.
1022        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    // ── api_create_id_query ───────────────────────────────────────────
1037
1038    #[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        // Per criterion: query ?id= wins over body.id.
1085        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    // ── api_orgtext_read ──────────────────────────────────────────────
1179
1180    #[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        // Body is the org rendering with a leading kb metadata drawer,
1195        // NOT the JSON envelope.
1196        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    // ── api_orgtext_write ─────────────────────────────────────────────
1284
1285    #[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        // Title comes from the parsed first heading.
1301        assert_eq!(v["title"].as_str().unwrap(), "Hello");
1302        // Server minted a UUID since text/org carries no body id.
1303        let id = v["id"].as_str().unwrap();
1304        assert!(uuid::Uuid::parse_str(id).is_ok());
1305    }
1306
1307    /// Whether `pandoc` is on `PATH` — markdown ingest tests skip without it.
1308    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        // text/org carries no body id; the only client-supplied id channel
1385        // is the ?id= query parameter.
1386        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        // An org body round-tripped from a prior export carries a leading
1405        // :PROPERTIES: drawer; it must not be persisted into the stored AST.
1406        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        // The drawer id is informational — a fresh UUID is minted.
1420        let id = v["id"].as_str().unwrap();
1421        assert!(uuid::Uuid::parse_str(id).is_ok());
1422        // The stored document has no property drawer; the heading is first.
1423        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        // A round-tripped org file whose drawer :ID: names a different
1434        // node must be rejected, not silently written to the path id.
1435        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        // The drawer did not leak into the stored AST.
1476        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        // Non-UTF-8 bytes for an org body.
1512        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    // ── api_orgtext_write_response ────────────────────────────────────
1542
1543    #[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        // Body is org text, not JSON.
1561        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    // ── search_endpoint_shape ─────────────────────────────────────────
1627
1628    /// `GET /search?q=…` continues to return a JSON array of NodeSummary
1629    /// objects (`{"id":..., "title":...}`), unchanged from v2/v3/v4. The
1630    /// backing call is now [`storage::search_hybrid`] but with no
1631    /// embedding hook configured it degrades to FTS, and the response
1632    /// envelope is identical.
1633    #[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        // NodeSummary shape: exactly the keys "id" and "title", both strings.
1671        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}