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