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