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