Skip to main content

notedthat_api_http/
error.rs

1//! HTTP API error types and JSON envelope.
2
3use axum::Json;
4use axum::http::header::{CONTENT_RANGE, RETRY_AFTER};
5use axum::http::{HeaderName, StatusCode};
6use axum::response::{IntoResponse, Response};
7use notedthat_core::{Error as CoreError, StorageError};
8use serde::Serialize;
9
10/// HTTP-layer error — maps domain and storage errors to HTTP status codes.
11#[derive(Debug, thiserror::Error)]
12pub enum ApiError {
13    /// The request lacked valid Bearer credentials.
14    #[error("unauthorized")]
15    Unauthorized,
16    /// The credential is valid, but the knowledge base's access policy does not
17    /// grant this operation on this key.
18    ///
19    /// Distinct from [`Self::Unauthorized`] on purpose: `401` tells a caller
20    /// that credentials might help, and `403` tells them that theirs are fine
21    /// and the answer is still no. D43 reserved this code for exactly this.
22    #[error("forbidden")]
23    Forbidden,
24    /// Indexer queue was full while enqueueing an upsert (PUT/COPY).
25    #[error("indexer upsert backpressure")]
26    IndexerBackpressureUpsert,
27    /// Indexer queue was full while enqueueing a tombstone (DELETE).
28    #[error("indexer tombstone backpressure")]
29    IndexerBackpressureTombstone,
30    /// The change event could not be published after storage had already
31    /// taken the write (D55). Answered like indexer backpressure: 503 with
32    /// `Retry-After`, so the client retries the idempotent write.
33    #[error("change event not published after the object was {after}")]
34    EventPublishFailed {
35        /// What storage had already done.
36        after: notedthat_write::WriteEffect,
37    },
38    /// A `Last-Event-ID` older than what the event log retains.
39    #[error("events after {requested} are no longer retained; oldest retained is {oldest}")]
40    EventsGone {
41        /// The position the subscriber asked to resume after.
42        requested: notedthat_core::EventId,
43        /// The oldest position still held.
44        oldest: notedthat_core::EventId,
45    },
46    /// The event log could not be subscribed to.
47    #[error("event backend unavailable: {message}")]
48    EventsUnavailable {
49        /// What the adapter reported.
50        message: String,
51    },
52    /// A domain error from `notedthat-core`.
53    ///
54    /// Built through the manual `From<CoreError>` rather than `#[from]`, so a
55    /// `CoreError::Storage(BucketNotFound)` — which core's own `From<StorageError>`
56    /// produces — is caught on the way in and answered like every other missing
57    /// bucket, with no bucket name on the wire.
58    #[error(transparent)]
59    Core(CoreError),
60    /// A storage-layer error not otherwise promoted to a top-level variant.
61    ///
62    /// Note: `StorageError::NotModified`, `StorageError::PreconditionFailed`, and
63    /// `StorageError::RangeNotSatisfiable` are promoted via the manual `From<StorageError>`
64    /// impl to top-level `ApiError` variants so that `IntoResponse` can emit the
65    /// RFC-required headers (e.g. `Content-Range: bytes */N` for 416).
66    #[error(transparent)]
67    Storage(StorageError),
68    /// A conditional PUT/DELETE failed because a precondition was not met.
69    ///
70    /// Maps to HTTP 412 Precondition Failed.
71    #[error("precondition failed")]
72    PreconditionFailed,
73    /// The requested byte range could not be satisfied.
74    ///
75    /// `complete_length` is the total object size in bytes.  The `IntoResponse`
76    /// impl emits `Content-Range: bytes */complete_length` per RFC 7233 §4.4.
77    #[error("range not satisfiable")]
78    RangeNotSatisfiable {
79        /// Total object size in bytes.
80        complete_length: u64,
81    },
82    /// The backend returned 304 Not Modified for a conditional GET/HEAD.
83    ///
84    /// Maps to HTTP 304 with no body, as required by RFC 7232 §4.1.
85    #[error("not modified")]
86    NotModified,
87    /// The `Range:` header value could not be parsed per RFC 7233.
88    ///
89    /// Maps to HTTP 400 Bad Request.
90    #[error("malformed range: {0}")]
91    MalformedRange(String),
92    /// A `Range: lines=…` header specified a range that exceeds the object's line count.
93    ///
94    /// Maps to HTTP 416 with `Content-Range: lines */<line_total>` and
95    /// `X-Content-Range-Bytes: */<byte_total>` per the line-range extension.
96    #[error("line range not satisfiable")]
97    LineRangeNotSatisfiable {
98        /// Total line count in the object.
99        line_total: u64,
100        /// Total byte count in the object.
101        byte_total: u64,
102    },
103    /// A single-replace operation found no occurrence of `old_string`.
104    #[error("no match found for old_string")]
105    ReplaceNoMatch,
106    /// A single-replace operation found multiple occurrences of `old_string`.
107    #[error("multiple matches found ({count}); use replace_all to replace them all")]
108    ReplaceAmbiguous {
109        /// Number of occurrences found.
110        count: u64,
111    },
112}
113
114/// Promote specific `StorageError` variants to top-level `ApiError` variants so
115/// that `IntoResponse` can emit the RFC-mandated response headers.
116impl From<CoreError> for ApiError {
117    fn from(e: CoreError) -> Self {
118        match e {
119            CoreError::Storage(StorageError::BucketNotFound { bucket }) => {
120                Self::bucket_not_found(&bucket)
121            }
122            other => Self::Core(other),
123        }
124    }
125}
126
127impl From<StorageError> for ApiError {
128    fn from(e: StorageError) -> Self {
129        match e {
130            StorageError::NotModified => Self::NotModified,
131            StorageError::PreconditionFailed => Self::PreconditionFailed,
132            StorageError::RangeNotSatisfiable { complete_length } => {
133                Self::RangeNotSatisfiable { complete_length }
134            }
135            StorageError::BucketNotFound { bucket } => Self::bucket_not_found(&bucket),
136            other => Self::Storage(other),
137        }
138    }
139}
140
141impl ApiError {
142    /// A declared knowledge base whose bucket is gone: `404 not_found` (D43) with a
143    /// message that names neither the bucket nor the tenant.
144    ///
145    /// `StorageError::BucketNotFound`'s own text is `bucket not found: nt-default-notes`,
146    /// which would tell every caller who reaches storage — an anonymous one on a
147    /// public knowledge base included — how buckets are named, and would make this
148    /// `404` distinguishable from the others on the wire. The bucket name goes to the
149    /// log, where the operator who has to put it back will look; the response stays
150    /// as uninformative as the concealed denial and the undeclared slug.
151    pub(crate) fn bucket_not_found(bucket: &str) -> Self {
152        tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
153        Self::Core(CoreError::NotFound {
154            resource: "knowledge base storage".to_string(),
155        })
156    }
157
158    /// The `message` of the JSON body: the error's own text, except for a
159    /// `BucketNotFound` that was built by hand around either wrapper rather than
160    /// through [`Self::bucket_not_found`] — that one still says only what the
161    /// helper would have said. Belt and braces: every `From` funnels the variant
162    /// through the helper, and this keeps a future `ApiError::Core(…)` literal
163    /// from undoing it.
164    fn message(&self) -> String {
165        match self {
166            Self::Core(CoreError::Storage(StorageError::BucketNotFound { bucket }))
167            | Self::Storage(StorageError::BucketNotFound { bucket }) => {
168                tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
169                "not found: knowledge base storage".to_string()
170            }
171            other => other.to_string(),
172        }
173    }
174}
175
176impl From<notedthat_write::WriteError> for ApiError {
177    fn from(e: notedthat_write::WriteError) -> Self {
178        match e {
179            notedthat_write::WriteError::Storage(e) => Self::from(e),
180            notedthat_write::WriteError::TooLarge { size, limit }
181            | notedthat_write::WriteError::PatchTooLarge { size, limit } => {
182                Self::Core(CoreError::PayloadTooLarge { size, limit })
183            }
184            notedthat_write::WriteError::Path(e) => Self::Core(e),
185            notedthat_write::WriteError::IndexerBackpressureUpsert => {
186                Self::IndexerBackpressureUpsert
187            }
188            notedthat_write::WriteError::IndexerBackpressureTombstone => {
189                Self::IndexerBackpressureTombstone
190            }
191            notedthat_write::WriteError::EventPublishFailed { after } => {
192                Self::EventPublishFailed { after }
193            }
194            notedthat_write::WriteError::PatchLineOutOfRange {
195                total_lines,
196                total_bytes,
197                ..
198            } => Self::LineRangeNotSatisfiable {
199                line_total: total_lines,
200                byte_total: total_bytes,
201            },
202            notedthat_write::WriteError::PatchInvalidRange { message }
203            | notedthat_write::WriteError::InvalidManifest { message } => {
204                Self::Core(CoreError::InvalidInput { message })
205            }
206            notedthat_write::WriteError::ReplaceNoMatch => Self::ReplaceNoMatch,
207            notedthat_write::WriteError::ReplaceAmbiguous { count } => {
208                Self::ReplaceAmbiguous { count }
209            }
210        }
211    }
212}
213
214/// JSON error response body shape: `{ "error": "code", "message": "...", "request_id": "..." }`.
215#[derive(Serialize)]
216struct ErrorBody<'a> {
217    error: &'a str,
218    message: String,
219    request_id: String,
220}
221
222#[derive(Serialize)]
223struct ReplaceAmbiguousBody<'a> {
224    error: &'a str,
225    message: String,
226    request_id: String,
227    match_count: u64,
228}
229
230/// An [`ApiError`] paired with a `request_id` string so the JSON body and the
231/// `x-request-id` response header both contain the same value.
232pub struct ApiErrorResponse {
233    /// The underlying error.
234    pub error: ApiError,
235    /// The request ID (from the `x-request-id` header via `tower-http`).
236    pub request_id: String,
237}
238
239impl ApiErrorResponse {
240    /// Build an unauthorized response with the provided request ID.
241    #[must_use]
242    pub fn unauthorized(request_id: String) -> Self {
243        Self {
244            error: ApiError::Unauthorized,
245            request_id,
246        }
247    }
248}
249
250impl ApiError {
251    fn status_and_code(&self) -> (StatusCode, &'static str) {
252        match self {
253            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
254            Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
255            Self::IndexerBackpressureUpsert
256            | Self::IndexerBackpressureTombstone
257            | Self::EventPublishFailed { .. }
258            | Self::EventsUnavailable { .. } => {
259                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
260            }
261            Self::EventsGone { .. } => (StatusCode::GONE, "gone"),
262            Self::Core(CoreError::InvalidInput { .. }) => {
263                (StatusCode::BAD_REQUEST, "invalid_request")
264            }
265            Self::Core(CoreError::NotFound { .. }) => (StatusCode::NOT_FOUND, "not_found"),
266            Self::Core(CoreError::PayloadTooLarge { .. }) => {
267                (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
268            }
269            Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
270                (StatusCode::BAD_REQUEST, "malformed_range")
271            }
272            Self::LineRangeNotSatisfiable { .. }
273            | Self::Core(CoreError::RangeNotSatisfiable { .. })
274            | Self::RangeNotSatisfiable { .. } => {
275                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
276            }
277            Self::Core(CoreError::NotModified) | Self::NotModified => {
278                (StatusCode::NOT_MODIFIED, "not_modified")
279            }
280            Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
281                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
282            }
283            Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
284            Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
285            Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
286                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
287            }
288            Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
289        }
290    }
291
292    fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
293        match e {
294            StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
295                (StatusCode::NOT_FOUND, "not_found")
296            }
297            StorageError::BackendUnavailable { .. } => {
298                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
299            }
300            StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
301            StorageError::PreconditionFailed => {
302                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
303            }
304            StorageError::RangeNotSatisfiable { .. } => {
305                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
306            }
307            StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
308        }
309    }
310
311    /// Extract the `complete_length` for a 416 response, regardless of which
312    /// wrapper the `RangeNotSatisfiable` error arrived in.
313    fn range_not_satisfiable_length(&self) -> Option<u64> {
314        match self {
315            Self::RangeNotSatisfiable { complete_length }
316            | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
317            | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
318                Some(*complete_length)
319            }
320            _ => None,
321        }
322    }
323
324    /// Return `true` for all variants that map to HTTP 304 (empty body required).
325    fn is_not_modified(&self) -> bool {
326        matches!(
327            self,
328            Self::NotModified
329                | Self::Storage(StorageError::NotModified)
330                | Self::Core(CoreError::NotModified)
331        )
332    }
333}
334
335impl ApiErrorResponse {
336    /// The D38 shape: storage already did the work, a queue behind it did not,
337    /// and the idempotent request should simply be repeated.
338    fn retry_later(request_id: String, message: String) -> Response {
339        let body = ErrorBody {
340            error: "backend_unavailable",
341            message,
342            request_id,
343        };
344        (
345            StatusCode::SERVICE_UNAVAILABLE,
346            [(RETRY_AFTER, "5")],
347            Json(body),
348        )
349            .into_response()
350    }
351}
352
353impl IntoResponse for ApiErrorResponse {
354    fn into_response(self) -> Response {
355        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
356        if let ApiError::LineRangeNotSatisfiable {
357            line_total,
358            byte_total,
359        } = &self.error
360        {
361            return (
362                StatusCode::RANGE_NOT_SATISFIABLE,
363                [
364                    (CONTENT_RANGE, format!("lines */{line_total}")),
365                    (
366                        HeaderName::from_static("x-content-range-bytes"),
367                        format!("*/{byte_total}"),
368                    ),
369                ],
370            )
371                .into_response();
372        }
373
374        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
375        // `Content-Range: bytes */N` and an empty body.
376        if let Some(complete_length) = self.error.range_not_satisfiable_length() {
377            let content_range = format!("bytes */{complete_length}");
378            return (
379                StatusCode::RANGE_NOT_SATISFIABLE,
380                [(CONTENT_RANGE, content_range)],
381            )
382                .into_response();
383        }
384
385        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
386        if self.error.is_not_modified() {
387            return StatusCode::NOT_MODIFIED.into_response();
388        }
389
390        match &self.error {
391            ApiError::Unauthorized => {
392                let body = ErrorBody {
393                    error: "unauthorized",
394                    message: "provide a valid Bearer token in the Authorization header".to_string(),
395                    request_id: self.request_id,
396                };
397                return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
398            }
399            ApiError::IndexerBackpressureUpsert => {
400                return Self::retry_later(
401                    self.request_id,
402                    "object stored; indexer queue full — retry to re-enqueue".to_string(),
403                );
404            }
405            ApiError::IndexerBackpressureTombstone => {
406                return Self::retry_later(
407                    self.request_id,
408                    "deleted from storage; retry to clear from search index".to_string(),
409                );
410            }
411            ApiError::EventPublishFailed { after } => {
412                let message = match after {
413                    notedthat_write::WriteEffect::Stored => {
414                        "object stored; change event not published — retry to publish"
415                    }
416                    notedthat_write::WriteEffect::Deleted => {
417                        "deleted from storage; change event not published — retry to publish"
418                    }
419                };
420                return Self::retry_later(self.request_id, message.to_string());
421            }
422            ApiError::EventsUnavailable { message } => {
423                return Self::retry_later(
424                    self.request_id,
425                    format!("event backend unavailable: {message}"),
426                );
427            }
428            ApiError::ReplaceAmbiguous { count } => {
429                let body = ReplaceAmbiguousBody {
430                    error: "ambiguous_match",
431                    message: self.error.to_string(),
432                    request_id: self.request_id,
433                    match_count: *count,
434                };
435                return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
436            }
437            _ => {}
438        }
439
440        // All other variants return a JSON error body.
441        let (status, code) = self.error.status_and_code();
442        let message = self.error.message();
443        let body = ErrorBody {
444            error: code,
445            message,
446            request_id: self.request_id,
447        };
448        (status, Json(body)).into_response()
449    }
450}
451
452impl IntoResponse for ApiError {
453    fn into_response(self) -> Response {
454        ApiErrorResponse {
455            error: self,
456            request_id: "unknown".to_string(),
457        }
458        .into_response()
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use axum::body::{Body, to_bytes};
466    use axum::http::Request;
467    use bytes::Bytes;
468    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
469    use notedthat_indexer::IndexEvent;
470    use notedthat_write::WriteError;
471    use std::collections::BTreeMap;
472    use std::sync::Arc;
473    use tower::util::ServiceExt;
474
475    const KB: &str = "notes";
476    const TOKEN: &str = "test-token-abc";
477
478    fn router() -> axum::Router {
479        router_with_max_patchable_size(16 * 1024 * 1024)
480    }
481
482    fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
483        let kb = KbSlug::try_new(KB).unwrap();
484        let mut kbs = BTreeMap::new();
485        kbs.insert(KB.to_string(), kb);
486        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
487        tokio::spawn(async move { while rx.recv().await.is_some() {} });
488
489        crate::router::build_router(crate::state::AppState {
490            storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
491            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
492            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
493            declared_kbs: Arc::new(kbs),
494            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
495            max_body_size: 16 * 1024 * 1024,
496            max_patchable_size,
497            indexer_tx,
498            searcher: Arc::new(crate::testing::NoopSearcher),
499            events: None,
500            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
501            readiness: crate::testing::ready_receiver(),
502        })
503    }
504
505    fn router_with_storage_and_indexer(
506        storage: Arc<dyn Storage>,
507        kb: KbSlug,
508        max_patchable_size: u64,
509        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
510    ) -> axum::Router {
511        let mut kbs = BTreeMap::new();
512        kbs.insert(KB.to_string(), kb);
513        crate::router::build_router(crate::state::AppState {
514            storage,
515            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
516            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
517            declared_kbs: Arc::new(kbs),
518            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
519            max_body_size: 16 * 1024 * 1024,
520            max_patchable_size,
521            indexer_tx,
522            searcher: Arc::new(crate::testing::NoopSearcher),
523            events: None,
524            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
525            readiness: crate::testing::ready_receiver(),
526        })
527    }
528
529    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
530        let response = router
531            .oneshot(
532                Request::builder()
533                    .method("PUT")
534                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
535                    .header("authorization", format!("Bearer {TOKEN}"))
536                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
537                    .body(Body::from(Bytes::from_static(body)))
538                    .unwrap(),
539            )
540            .await
541            .unwrap();
542
543        assert_eq!(response.status(), StatusCode::CREATED);
544        response
545            .headers()
546            .get(axum::http::header::ETAG)
547            .unwrap()
548            .to_str()
549            .unwrap()
550            .to_string()
551    }
552
553    async fn get_object(router: axum::Router, path: &str) -> Bytes {
554        let response = router
555            .oneshot(
556                Request::builder()
557                    .method("GET")
558                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
559                    .header("authorization", format!("Bearer {TOKEN}"))
560                    .body(Body::empty())
561                    .unwrap(),
562            )
563            .await
564            .unwrap();
565
566        assert_eq!(response.status(), StatusCode::OK);
567        to_bytes(response.into_body(), usize::MAX).await.unwrap()
568    }
569
570    async fn post_replace(
571        router: axum::Router,
572        path: &str,
573        if_match: &str,
574        body: &'static [u8],
575    ) -> Response {
576        router
577            .oneshot(
578                Request::builder()
579                    .method("POST")
580                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
581                    .header("authorization", format!("Bearer {TOKEN}"))
582                    .header(axum::http::header::CONTENT_TYPE, "application/json")
583                    .header(axum::http::header::IF_MATCH, if_match)
584                    .body(Body::from(Bytes::from_static(body)))
585                    .unwrap(),
586            )
587            .await
588            .unwrap()
589    }
590
591    async fn object_with_etag(
592        storage: &crate::testing::InMemoryStorage,
593        kb: &KbSlug,
594        path: &str,
595        body: &'static [u8],
596    ) -> String {
597        storage
598            .put_object(
599                kb,
600                &ObjectPath::try_from_str(path).unwrap(),
601                Bytes::from_static(body),
602                Some("text/markdown"),
603                ConditionalHeaders::default(),
604            )
605            .await
606            .unwrap()
607            .etag
608            .unwrap()
609    }
610
611    async fn assert_invalid_request_response(response: Response) {
612        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
613        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
614        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
615        assert_eq!(json["error"], "invalid_request");
616    }
617
618    #[tokio::test]
619    async fn test_unauthorized_status_and_body() {
620        let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
621        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
622        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
623        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
624        assert_eq!(json["error"], "unauthorized");
625        assert_eq!(json["request_id"], "req-123");
626    }
627
628    #[tokio::test]
629    async fn test_not_found_status() {
630        let err = ApiError::Core(CoreError::NotFound {
631            resource: "foo".into(),
632        });
633        let resp = ApiErrorResponse {
634            error: err,
635            request_id: "rid".into(),
636        }
637        .into_response();
638        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
639    }
640
641    #[tokio::test]
642    async fn test_payload_too_large_status() {
643        let err = ApiError::Core(CoreError::PayloadTooLarge {
644            size: 20_000_000,
645            limit: 16_777_216,
646        });
647        let resp = ApiErrorResponse {
648            error: err,
649            request_id: "rid".into(),
650        }
651        .into_response();
652        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
653    }
654
655    #[tokio::test]
656    async fn test_request_id_in_body() {
657        let err = ApiError::Core(CoreError::InvalidInput {
658            message: "bad".into(),
659        });
660        let resp = ApiErrorResponse {
661            error: err,
662            request_id: "my-req-id".into(),
663        }
664        .into_response();
665        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
666        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
667        assert_eq!(json["request_id"], "my-req-id");
668    }
669
670    #[tokio::test]
671    async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
672        let resp = ApiErrorResponse {
673            error: ApiError::IndexerBackpressureUpsert,
674            request_id: "rid".to_string(),
675        }
676        .into_response();
677
678        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
679        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
680        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
681        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
682        assert_eq!(json["error"], "backend_unavailable");
683        assert_eq!(
684            json["message"],
685            "object stored; indexer queue full — retry to re-enqueue"
686        );
687        assert_eq!(json["request_id"], "rid");
688    }
689
690    #[tokio::test]
691    async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
692        let resp = ApiErrorResponse {
693            error: ApiError::IndexerBackpressureTombstone,
694            request_id: "rid".to_string(),
695        }
696        .into_response();
697
698        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
699        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
700        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
701        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
702        assert_eq!(json["error"], "backend_unavailable");
703        assert_eq!(
704            json["message"],
705            "deleted from storage; retry to clear from search index"
706        );
707        assert_eq!(json["request_id"], "rid");
708    }
709
710    #[test]
711    fn test_from_write_error_indexer_backpressure() {
712        assert!(matches!(
713            ApiError::from(WriteError::IndexerBackpressureUpsert),
714            ApiError::IndexerBackpressureUpsert
715        ));
716        assert!(matches!(
717            ApiError::from(WriteError::IndexerBackpressureTombstone),
718            ApiError::IndexerBackpressureTombstone
719        ));
720    }
721
722    #[test]
723    fn test_from_write_error_patch_too_large() {
724        let api_err = ApiError::from(WriteError::PatchTooLarge {
725            size: 200 * 1024 * 1024,
726            limit: 100 * 1024 * 1024,
727        });
728
729        let (status, code) = api_err.status_and_code();
730        assert_eq!(status.as_u16(), 413);
731        assert_eq!(code, "payload_too_large");
732    }
733
734    #[test]
735    fn test_from_write_error_patch_line_out_of_range() {
736        let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
737            first: 999,
738            last: 1000,
739            total_lines: 20,
740            total_bytes: 100,
741        });
742
743        let (status, code) = api_err.status_and_code();
744        assert_eq!(status.as_u16(), 416);
745        assert_eq!(code, "range_not_satisfiable");
746    }
747
748    #[test]
749    fn test_from_write_error_patch_invalid_range() {
750        let api_err = ApiError::from(WriteError::PatchInvalidRange {
751            message: "test".into(),
752        });
753
754        let (status, code) = api_err.status_and_code();
755        assert_eq!(status.as_u16(), 400);
756        assert_eq!(code, "invalid_request");
757    }
758
759    /// A manifest the write path refused is the boot's own message, as a `400`.
760    #[test]
761    fn test_from_write_error_invalid_manifest() {
762        let api_err = ApiError::from(WriteError::InvalidManifest {
763            message: "description must be a single line without control characters".into(),
764        });
765
766        let (status, code) = api_err.status_and_code();
767        assert_eq!(status.as_u16(), 400);
768        assert_eq!(code, "invalid_request");
769        assert!(api_err.to_string().contains("description"));
770    }
771
772    #[test]
773    fn test_from_write_error_replace_no_match() {
774        let api_err = ApiError::from(WriteError::ReplaceNoMatch);
775
776        let (status, code) = api_err.status_and_code();
777        assert_eq!(status.as_u16(), 422);
778        assert_eq!(code, "no_match");
779    }
780
781    #[test]
782    fn test_from_write_error_replace_ambiguous() {
783        let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
784
785        let (status, code) = api_err.status_and_code();
786        assert_eq!(status.as_u16(), 422);
787        assert_eq!(code, "ambiguous_match");
788    }
789
790    #[tokio::test]
791    async fn test_ambiguous_match_body_includes_match_count() {
792        let resp = ApiErrorResponse {
793            error: ApiError::ReplaceAmbiguous { count: 3 },
794            request_id: "req-1".into(),
795        }
796        .into_response();
797
798        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
799        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
800        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
801        assert_eq!(json["error"], "ambiguous_match");
802        assert_eq!(json["match_count"], 3);
803        assert_eq!(json["request_id"], "req-1");
804    }
805
806    #[tokio::test]
807    async fn test_no_match_body_omits_match_count() {
808        let resp = ApiErrorResponse {
809            error: ApiError::ReplaceNoMatch,
810            request_id: "req-1".into(),
811        }
812        .into_response();
813
814        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
815        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
816        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
817        assert_eq!(json["error"], "no_match");
818        assert!(json.get("match_count").is_none());
819    }
820
821    #[tokio::test]
822    async fn replace_missing_if_match_returns_400_invalid_request() {
823        let response = router()
824            .oneshot(
825                Request::builder()
826                    .method("POST")
827                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
828                    .header("authorization", format!("Bearer {TOKEN}"))
829                    .header(axum::http::header::CONTENT_TYPE, "application/json")
830                    .body(Body::from(Bytes::from_static(
831                        br#"{"old_string":"x","new_string":"y"}"#,
832                    )))
833                    .unwrap(),
834            )
835            .await
836            .unwrap();
837
838        assert_invalid_request_response(response).await;
839    }
840
841    #[tokio::test]
842    async fn replace_if_match_star_returns_400() {
843        let response = router()
844            .oneshot(
845                Request::builder()
846                    .method("POST")
847                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
848                    .header("authorization", format!("Bearer {TOKEN}"))
849                    .header(axum::http::header::CONTENT_TYPE, "application/json")
850                    .header(axum::http::header::IF_MATCH, "*")
851                    .body(Body::from(Bytes::from_static(
852                        br#"{"old_string":"x","new_string":"y"}"#,
853                    )))
854                    .unwrap(),
855            )
856            .await
857            .unwrap();
858
859        assert_invalid_request_response(response).await;
860    }
861
862    #[tokio::test]
863    async fn replace_multi_value_if_match_returns_400() {
864        let response = router()
865            .oneshot(
866                Request::builder()
867                    .method("POST")
868                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
869                    .header("authorization", format!("Bearer {TOKEN}"))
870                    .header(axum::http::header::CONTENT_TYPE, "application/json")
871                    .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
872                    .body(Body::from(Bytes::from_static(
873                        br#"{"old_string":"x","new_string":"y"}"#,
874                    )))
875                    .unwrap(),
876            )
877            .await
878            .unwrap();
879
880        assert_invalid_request_response(response).await;
881    }
882
883    #[tokio::test]
884    async fn replace_malformed_json_body_returns_400() {
885        let response = router()
886            .oneshot(
887                Request::builder()
888                    .method("POST")
889                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
890                    .header("authorization", format!("Bearer {TOKEN}"))
891                    .header(axum::http::header::CONTENT_TYPE, "application/json")
892                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
893                    .body(Body::from(Bytes::from_static(b"{invalid json")))
894                    .unwrap(),
895            )
896            .await
897            .unwrap();
898
899        assert_invalid_request_response(response).await;
900    }
901
902    #[tokio::test]
903    async fn replace_missing_old_string_field_returns_400() {
904        let response = router()
905            .oneshot(
906                Request::builder()
907                    .method("POST")
908                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
909                    .header("authorization", format!("Bearer {TOKEN}"))
910                    .header(axum::http::header::CONTENT_TYPE, "application/json")
911                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
912                    .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
913                    .unwrap(),
914            )
915            .await
916            .unwrap();
917
918        assert_invalid_request_response(response).await;
919    }
920
921    #[tokio::test]
922    async fn replace_empty_old_string_returns_400() {
923        let response = router()
924            .oneshot(
925                Request::builder()
926                    .method("POST")
927                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
928                    .header("authorization", format!("Bearer {TOKEN}"))
929                    .header(axum::http::header::CONTENT_TYPE, "application/json")
930                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
931                    .body(Body::from(Bytes::from_static(
932                        br#"{"old_string":"","new_string":"y"}"#,
933                    )))
934                    .unwrap(),
935            )
936            .await
937            .unwrap();
938
939        assert_invalid_request_response(response).await;
940    }
941
942    #[tokio::test]
943    async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
944        let router = router();
945        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
946
947        let response = post_replace(
948            router.clone(),
949            "hello.md",
950            &etag,
951            br#"{"old_string":"world","new_string":"planet"}"#,
952        )
953        .await;
954
955        assert_eq!(response.status(), StatusCode::OK);
956        assert!(response.headers().get(axum::http::header::ETAG).is_some());
957        assert_eq!(
958            response.headers().get("content-location").unwrap(),
959            "/api/v1/knowledgebases/notes/hello.md"
960        );
961        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
962        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
963        assert!(json["etag"].as_str().is_some());
964        assert_eq!(json["match_count"], 1);
965        assert_eq!(json["total_bytes"], 12);
966        assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
967    }
968
969    #[tokio::test]
970    async fn replace_no_match_returns_422_no_match() {
971        let router = router();
972        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
973
974        let response = post_replace(
975            router.clone(),
976            "hello.md",
977            &etag,
978            br#"{"old_string":"nonexistent","new_string":"x"}"#,
979        )
980        .await;
981
982        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
983        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
984        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
985        assert_eq!(json["error"], "no_match");
986        assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
987    }
988
989    #[tokio::test]
990    async fn replace_ambiguous_returns_422_with_match_count() {
991        let router = router();
992        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
993
994        let response = post_replace(
995            router.clone(),
996            "hello.md",
997            &etag,
998            br#"{"old_string":"a","new_string":"Z"}"#,
999        )
1000        .await;
1001
1002        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1003        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1004        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1005        assert_eq!(json["error"], "ambiguous_match");
1006        assert_eq!(json["match_count"], 2);
1007        assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1008    }
1009
1010    #[tokio::test]
1011    async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1012        let router = router();
1013        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1014
1015        let response = post_replace(
1016            router.clone(),
1017            "hello.md",
1018            &etag,
1019            br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1020        )
1021        .await;
1022
1023        assert_eq!(response.status(), StatusCode::OK);
1024        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1025        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1026        assert_eq!(json["match_count"], 2);
1027        assert_eq!(json["total_bytes"], 5);
1028        assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1029    }
1030
1031    #[tokio::test]
1032    async fn replace_stale_etag_returns_412() {
1033        let router = router();
1034        put_object(router.clone(), "hello.md", b"hello world").await;
1035
1036        let response = post_replace(
1037            router,
1038            "hello.md",
1039            "\"stale\"",
1040            br#"{"old_string":"world","new_string":"planet"}"#,
1041        )
1042        .await;
1043
1044        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1045    }
1046
1047    #[tokio::test]
1048    async fn replace_post_splice_size_over_cap_returns_413() {
1049        let router = router_with_max_patchable_size(20);
1050        let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1051
1052        let response = post_replace(
1053            router,
1054            "hello.md",
1055            &etag,
1056            br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1057        )
1058        .await;
1059
1060        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1061        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1062        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1063        assert_eq!(json["error"], "payload_too_large");
1064    }
1065
1066    #[tokio::test]
1067    async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1068        let kb = KbSlug::try_new(KB).unwrap();
1069        let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1070        let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1071        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1072        indexer_tx
1073            .try_send(IndexEvent::Upsert {
1074                kb: kb.clone(),
1075                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1076                etag: "queued".to_string(),
1077                mtime: 0,
1078            })
1079            .unwrap();
1080        let router =
1081            router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1082
1083        let response = post_replace(
1084            router,
1085            "hello.md",
1086            &etag,
1087            br#"{"old_string":"world","new_string":"planet"}"#,
1088        )
1089        .await;
1090
1091        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1092        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1093        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1094        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1095        assert_eq!(json["error"], "backend_unavailable");
1096    }
1097
1098    #[tokio::test]
1099    async fn test_precondition_failed_body_shape_unchanged() {
1100        let resp = ApiErrorResponse {
1101            error: ApiError::PreconditionFailed,
1102            request_id: "req-1".into(),
1103        }
1104        .into_response();
1105
1106        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1107        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1108        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1109        let object = json.as_object().unwrap();
1110        assert_eq!(object.len(), 3);
1111        assert!(object.contains_key("error"));
1112        assert!(object.contains_key("message"));
1113        assert!(object.contains_key("request_id"));
1114    }
1115
1116    // ─── New variant tests ────────────────────────────────────────────────────
1117
1118    #[tokio::test]
1119    async fn test_precondition_failed_412() {
1120        let resp = ApiError::PreconditionFailed.into_response();
1121        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_not_modified_304_empty_body() {
1126        let resp = ApiError::NotModified.into_response();
1127        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1128        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1129        assert!(body.is_empty(), "304 must have an empty body");
1130    }
1131
1132    #[tokio::test]
1133    async fn test_malformed_range_400() {
1134        let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1135        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1136    }
1137
1138    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1139    #[tokio::test]
1140    async fn test_range_not_satisfiable_416_content_range_header() {
1141        let resp = ApiError::RangeNotSatisfiable {
1142            complete_length: 100,
1143        }
1144        .into_response();
1145        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1146        let cr = resp
1147            .headers()
1148            .get("content-range")
1149            .expect("content-range header must be present on 416");
1150        assert_eq!(cr.to_str().unwrap(), "bytes */100");
1151        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1152        assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1153    }
1154
1155    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1156    /// call sites that use explicit wrapping instead of `Into::into`).
1157    #[tokio::test]
1158    async fn test_storage_range_not_satisfiable_416_content_range_header() {
1159        let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1160            complete_length: 42,
1161        })
1162        .into_response();
1163        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1164        let cr = resp
1165            .headers()
1166            .get("content-range")
1167            .expect("content-range header must be present on 416");
1168        assert_eq!(cr.to_str().unwrap(), "bytes */42");
1169    }
1170
1171    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1172    #[tokio::test]
1173    async fn test_storage_not_modified_304_empty_body() {
1174        let resp = ApiError::Storage(StorageError::NotModified).into_response();
1175        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1176        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1177        assert!(body.is_empty(), "304 must have an empty body");
1178    }
1179
1180    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1181    #[test]
1182    fn test_from_storage_error_not_modified() {
1183        let api_err = ApiError::from(StorageError::NotModified);
1184        assert!(matches!(api_err, ApiError::NotModified));
1185    }
1186
1187    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1188    #[test]
1189    fn test_from_storage_error_precondition_failed() {
1190        let api_err = ApiError::from(StorageError::PreconditionFailed);
1191        assert!(matches!(api_err, ApiError::PreconditionFailed));
1192    }
1193
1194    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1195    #[test]
1196    fn test_from_storage_error_range_not_satisfiable() {
1197        let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1198            complete_length: 999,
1199        });
1200        assert!(
1201            matches!(
1202                api_err,
1203                ApiError::RangeNotSatisfiable {
1204                    complete_length: 999
1205                }
1206            ),
1207            "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1208        );
1209    }
1210
1211    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1212    #[test]
1213    fn test_from_storage_error_other_wrapped() {
1214        let api_err = ApiError::from(StorageError::NotFound {
1215            key: "foo".to_string(),
1216        });
1217        assert!(matches!(api_err, ApiError::Storage(_)));
1218    }
1219
1220    /// Core's own `From<StorageError>` yields `CoreError::Storage(BucketNotFound)`;
1221    /// one `.map_err(CoreError::from)` in a handler must not reopen the bucket name
1222    /// that `From<StorageError>` closes.
1223    #[test]
1224    fn test_from_core_error_bucket_not_found_is_sanitised() {
1225        let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1226            bucket: "nt-default-notes".to_string(),
1227        }));
1228        assert!(
1229            matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1230            "{api_err:?}"
1231        );
1232        // Every other core error is wrapped as it is.
1233        let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1234            key: "a.md".to_string(),
1235        }));
1236        assert!(matches!(
1237            api_err,
1238            ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1239        ));
1240    }
1241
1242    /// Even a `BucketNotFound` built by hand around either wrapper renders the
1243    /// fixed message: `404 not_found`, and neither the bucket nor the tenant naming
1244    /// scheme in the body.
1245    #[tokio::test]
1246    async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1247        for error in [
1248            ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1249                bucket: "nt-acme-notes".to_string(),
1250            })),
1251            ApiError::Storage(StorageError::BucketNotFound {
1252                bucket: "nt-acme-notes".to_string(),
1253            }),
1254        ] {
1255            let resp = ApiErrorResponse {
1256                error,
1257                request_id: "req-1".into(),
1258            }
1259            .into_response();
1260            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1261            let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1262            let text = String::from_utf8(body.to_vec()).unwrap();
1263            let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1264            assert_eq!(json["error"], "not_found");
1265            assert_eq!(json["message"], "not found: knowledge base storage");
1266            assert!(!text.contains("nt-"), "{text}");
1267            assert!(!text.contains("bucket"), "{text}");
1268        }
1269    }
1270
1271    mod line_range_error {
1272        use super::*;
1273        use axum::body::Body;
1274        use axum::http::Request;
1275        use bytes::Bytes;
1276        use notedthat_core::KbSlug;
1277        use std::collections::BTreeMap;
1278        use std::sync::Arc;
1279        use tower::util::ServiceExt;
1280
1281        const KB: &str = "notes";
1282        const TOKEN: &str = "test-token-abc";
1283
1284        fn twenty_line_markdown() -> String {
1285            let mut body = String::new();
1286            for line in 1..=20 {
1287                std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1288            }
1289            body
1290        }
1291
1292        fn router() -> axum::Router {
1293            let kb = KbSlug::try_new(KB).unwrap();
1294            let mut kbs = BTreeMap::new();
1295            kbs.insert(KB.to_string(), kb);
1296            let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1297            tokio::spawn(async move { while rx.recv().await.is_some() {} });
1298
1299            crate::router::build_router(crate::state::AppState {
1300                storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1301                access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1302                kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1303                declared_kbs: Arc::new(kbs),
1304                authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1305                max_body_size: 16 * 1024 * 1024,
1306                max_patchable_size: 16 * 1024 * 1024,
1307                indexer_tx,
1308                searcher: Arc::new(crate::testing::NoopSearcher),
1309                events: None,
1310                index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1311                readiness: crate::testing::ready_receiver(),
1312            })
1313        }
1314
1315        async fn put_ranges_md(router: axum::Router) {
1316            let response = router
1317                .oneshot(
1318                    Request::builder()
1319                        .method("PUT")
1320                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1321                        .header("authorization", format!("Bearer {TOKEN}"))
1322                        .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1323                        .body(Body::from(Bytes::from(twenty_line_markdown())))
1324                        .unwrap(),
1325                )
1326                .await
1327                .unwrap();
1328
1329            assert_eq!(response.status(), StatusCode::CREATED);
1330        }
1331
1332        #[tokio::test]
1333        async fn malformed_line_range_returns_json_400() {
1334            let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1335
1336            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1337            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1338            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1339            assert_eq!(json["error"], "malformed_range");
1340        }
1341
1342        #[tokio::test]
1343        async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1344            let response = ApiError::LineRangeNotSatisfiable {
1345                line_total: 20,
1346                byte_total: 100,
1347            }
1348            .into_response();
1349
1350            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1351            assert_eq!(
1352                response.headers().get("content-range").unwrap(),
1353                "lines */20"
1354            );
1355            assert_eq!(
1356                response.headers().get("x-content-range-bytes").unwrap(),
1357                "*/100"
1358            );
1359            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1360            assert!(body.is_empty());
1361        }
1362
1363        #[tokio::test]
1364        async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1365            let router = router();
1366            put_ranges_md(router.clone()).await;
1367
1368            let response = router
1369                .oneshot(
1370                    Request::builder()
1371                        .method("GET")
1372                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1373                        .header("authorization", format!("Bearer {TOKEN}"))
1374                        .header(axum::http::header::RANGE, "lines=100-200")
1375                        .body(Body::empty())
1376                        .unwrap(),
1377                )
1378                .await
1379                .unwrap();
1380
1381            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1382            assert_eq!(
1383                response.headers().get("content-range").unwrap(),
1384                "lines */20"
1385            );
1386            assert_eq!(
1387                response.headers().get("x-content-range-bytes").unwrap(),
1388                "*/160"
1389            );
1390            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1391            assert!(body.is_empty());
1392        }
1393
1394        #[tokio::test]
1395        async fn byte_range_not_satisfiable_omits_line_byte_header() {
1396            let response = ApiError::RangeNotSatisfiable {
1397                complete_length: 100,
1398            }
1399            .into_response();
1400
1401            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1402            assert_eq!(
1403                response.headers().get("content-range").unwrap(),
1404                "bytes */100"
1405            );
1406            assert!(response.headers().get("x-content-range-bytes").is_none());
1407        }
1408
1409        #[test]
1410        fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1411            let error = ApiError::from(WriteError::PatchLineOutOfRange {
1412                first: 100,
1413                last: 200,
1414                total_lines: 20,
1415                total_bytes: 100,
1416            });
1417
1418            assert!(matches!(
1419                error,
1420                ApiError::LineRangeNotSatisfiable {
1421                    line_total: 20,
1422                    byte_total: 100
1423                }
1424            ));
1425        }
1426    }
1427}