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