Skip to main content

notedthat_api_http/
error.rs

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