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,
547 searcher: Arc::new(crate::testing::NoopSearcher),
548 events: None,
549 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
550 readiness: crate::testing::ready_receiver(),
551 reconcile: None,
552 })
553 }
554
555 fn router_with_storage_and_indexer(
556 storage: Arc<dyn Storage>,
557 kb: KbSlug,
558 max_patchable_size: u64,
559 indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
560 ) -> axum::Router {
561 let mut kbs = BTreeMap::new();
562 kbs.insert(KB.to_string(), kb);
563 crate::router::build_router(crate::state::AppState {
564 storage,
565 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
566 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
567 declared_kbs: Arc::new(kbs),
568 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
569 max_body_size: 16 * 1024 * 1024,
570 max_patchable_size,
571 indexer_tx,
572 searcher: Arc::new(crate::testing::NoopSearcher),
573 events: None,
574 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
575 readiness: crate::testing::ready_receiver(),
576 reconcile: None,
577 })
578 }
579
580 async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
581 let response = router
582 .oneshot(
583 Request::builder()
584 .method("PUT")
585 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
586 .header("authorization", format!("Bearer {TOKEN}"))
587 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
588 .body(Body::from(Bytes::from_static(body)))
589 .unwrap(),
590 )
591 .await
592 .unwrap();
593
594 assert_eq!(response.status(), StatusCode::CREATED);
595 response
596 .headers()
597 .get(axum::http::header::ETAG)
598 .unwrap()
599 .to_str()
600 .unwrap()
601 .to_string()
602 }
603
604 async fn get_object(router: axum::Router, path: &str) -> Bytes {
605 let response = router
606 .oneshot(
607 Request::builder()
608 .method("GET")
609 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
610 .header("authorization", format!("Bearer {TOKEN}"))
611 .body(Body::empty())
612 .unwrap(),
613 )
614 .await
615 .unwrap();
616
617 assert_eq!(response.status(), StatusCode::OK);
618 to_bytes(response.into_body(), usize::MAX).await.unwrap()
619 }
620
621 async fn post_replace(
622 router: axum::Router,
623 path: &str,
624 if_match: &str,
625 body: &'static [u8],
626 ) -> Response {
627 router
628 .oneshot(
629 Request::builder()
630 .method("POST")
631 .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
632 .header("authorization", format!("Bearer {TOKEN}"))
633 .header(axum::http::header::CONTENT_TYPE, "application/json")
634 .header(axum::http::header::IF_MATCH, if_match)
635 .body(Body::from(Bytes::from_static(body)))
636 .unwrap(),
637 )
638 .await
639 .unwrap()
640 }
641
642 async fn object_with_etag(
643 storage: &crate::testing::InMemoryStorage,
644 kb: &KbSlug,
645 path: &str,
646 body: &'static [u8],
647 ) -> String {
648 storage
649 .put_object(
650 kb,
651 &ObjectPath::try_from_str(path).unwrap(),
652 Bytes::from_static(body),
653 Some("text/markdown"),
654 ConditionalHeaders::default(),
655 )
656 .await
657 .unwrap()
658 .etag
659 .unwrap()
660 }
661
662 async fn assert_invalid_request_response(response: Response) {
663 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
664 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
665 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
666 assert_eq!(json["error"], "invalid_request");
667 }
668
669 #[tokio::test]
670 async fn test_unauthorized_status_and_body() {
671 let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
672 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
673 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
674 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
675 assert_eq!(json["error"], "unauthorized");
676 assert_eq!(json["request_id"], "req-123");
677 }
678
679 #[tokio::test]
680 async fn test_not_found_status() {
681 let err = ApiError::Core(CoreError::NotFound {
682 resource: "foo".into(),
683 });
684 let resp = ApiErrorResponse {
685 error: err,
686 request_id: "rid".into(),
687 }
688 .into_response();
689 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
690 }
691
692 #[tokio::test]
693 async fn test_payload_too_large_status() {
694 let err = ApiError::Core(CoreError::PayloadTooLarge {
695 size: 20_000_000,
696 limit: 16_777_216,
697 });
698 let resp = ApiErrorResponse {
699 error: err,
700 request_id: "rid".into(),
701 }
702 .into_response();
703 assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
704 }
705
706 #[tokio::test]
707 async fn test_request_id_in_body() {
708 let err = ApiError::Core(CoreError::InvalidInput {
709 message: "bad".into(),
710 });
711 let resp = ApiErrorResponse {
712 error: err,
713 request_id: "my-req-id".into(),
714 }
715 .into_response();
716 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
717 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
718 assert_eq!(json["request_id"], "my-req-id");
719 }
720
721 #[tokio::test]
722 async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
723 let resp = ApiErrorResponse {
724 error: ApiError::IndexerBackpressureUpsert,
725 request_id: "rid".to_string(),
726 }
727 .into_response();
728
729 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
730 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
731 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
732 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
733 assert_eq!(json["error"], "backend_unavailable");
734 assert_eq!(
735 json["message"],
736 "object stored; indexer queue full — retry to re-enqueue"
737 );
738 assert_eq!(json["request_id"], "rid");
739 }
740
741 #[tokio::test]
742 async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
743 let resp = ApiErrorResponse {
744 error: ApiError::IndexerBackpressureTombstone,
745 request_id: "rid".to_string(),
746 }
747 .into_response();
748
749 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
750 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
751 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
752 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
753 assert_eq!(json["error"], "backend_unavailable");
754 assert_eq!(
755 json["message"],
756 "deleted from storage; retry to clear from search index"
757 );
758 assert_eq!(json["request_id"], "rid");
759 }
760
761 #[test]
762 fn test_from_write_error_indexer_backpressure() {
763 assert!(matches!(
764 ApiError::from(WriteError::IndexerBackpressureUpsert),
765 ApiError::IndexerBackpressureUpsert
766 ));
767 assert!(matches!(
768 ApiError::from(WriteError::IndexerBackpressureTombstone),
769 ApiError::IndexerBackpressureTombstone
770 ));
771 }
772
773 #[test]
774 fn test_from_write_error_patch_too_large() {
775 let api_err = ApiError::from(WriteError::PatchTooLarge {
776 size: 200 * 1024 * 1024,
777 limit: 100 * 1024 * 1024,
778 });
779
780 let (status, code) = api_err.status_and_code();
781 assert_eq!(status.as_u16(), 413);
782 assert_eq!(code, "payload_too_large");
783 }
784
785 #[test]
786 fn test_from_write_error_patch_line_out_of_range() {
787 let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
788 first: 999,
789 last: 1000,
790 total_lines: 20,
791 total_bytes: 100,
792 });
793
794 let (status, code) = api_err.status_and_code();
795 assert_eq!(status.as_u16(), 416);
796 assert_eq!(code, "range_not_satisfiable");
797 }
798
799 #[test]
800 fn test_from_write_error_patch_invalid_range() {
801 let api_err = ApiError::from(WriteError::PatchInvalidRange {
802 message: "test".into(),
803 });
804
805 let (status, code) = api_err.status_and_code();
806 assert_eq!(status.as_u16(), 400);
807 assert_eq!(code, "invalid_request");
808 }
809
810 #[test]
812 fn test_from_write_error_invalid_manifest() {
813 let api_err = ApiError::from(WriteError::InvalidManifest {
814 message: "description must be a single line without control characters".into(),
815 });
816
817 let (status, code) = api_err.status_and_code();
818 assert_eq!(status.as_u16(), 400);
819 assert_eq!(code, "invalid_request");
820 assert!(api_err.to_string().contains("description"));
821 }
822
823 #[test]
826 fn a_reconcile_in_progress_is_a_conflict() {
827 let (status, code) = ApiError::ReconcileInProgress.status_and_code();
828 assert_eq!(status.as_u16(), 409);
829 assert_eq!(code, "conflict");
830 assert!(
831 ApiError::ReconcileInProgress
832 .to_string()
833 .contains("last_reconcile")
834 );
835 }
836
837 #[test]
839 fn an_unsupported_reconcile_is_not_found_and_names_the_backend_that_has_one() {
840 let (status, code) = ApiError::ReconcileUnsupported.status_and_code();
841 assert_eq!(status.as_u16(), 404);
842 assert_eq!(code, "not_found");
843 assert!(ApiError::ReconcileUnsupported.to_string().contains("s3"));
844 }
845
846 #[test]
847 fn test_from_write_error_replace_no_match() {
848 let api_err = ApiError::from(WriteError::ReplaceNoMatch);
849
850 let (status, code) = api_err.status_and_code();
851 assert_eq!(status.as_u16(), 422);
852 assert_eq!(code, "no_match");
853 }
854
855 #[test]
856 fn test_from_write_error_replace_ambiguous() {
857 let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
858
859 let (status, code) = api_err.status_and_code();
860 assert_eq!(status.as_u16(), 422);
861 assert_eq!(code, "ambiguous_match");
862 }
863
864 #[tokio::test]
865 async fn test_ambiguous_match_body_includes_match_count() {
866 let resp = ApiErrorResponse {
867 error: ApiError::ReplaceAmbiguous { count: 3 },
868 request_id: "req-1".into(),
869 }
870 .into_response();
871
872 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
873 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
874 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
875 assert_eq!(json["error"], "ambiguous_match");
876 assert_eq!(json["match_count"], 3);
877 assert_eq!(json["request_id"], "req-1");
878 }
879
880 #[tokio::test]
881 async fn test_no_match_body_omits_match_count() {
882 let resp = ApiErrorResponse {
883 error: ApiError::ReplaceNoMatch,
884 request_id: "req-1".into(),
885 }
886 .into_response();
887
888 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
889 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
890 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
891 assert_eq!(json["error"], "no_match");
892 assert!(json.get("match_count").is_none());
893 }
894
895 #[tokio::test]
896 async fn replace_missing_if_match_returns_400_invalid_request() {
897 let response = router()
898 .oneshot(
899 Request::builder()
900 .method("POST")
901 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
902 .header("authorization", format!("Bearer {TOKEN}"))
903 .header(axum::http::header::CONTENT_TYPE, "application/json")
904 .body(Body::from(Bytes::from_static(
905 br#"{"old_string":"x","new_string":"y"}"#,
906 )))
907 .unwrap(),
908 )
909 .await
910 .unwrap();
911
912 assert_invalid_request_response(response).await;
913 }
914
915 #[tokio::test]
916 async fn replace_if_match_star_returns_400() {
917 let response = router()
918 .oneshot(
919 Request::builder()
920 .method("POST")
921 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
922 .header("authorization", format!("Bearer {TOKEN}"))
923 .header(axum::http::header::CONTENT_TYPE, "application/json")
924 .header(axum::http::header::IF_MATCH, "*")
925 .body(Body::from(Bytes::from_static(
926 br#"{"old_string":"x","new_string":"y"}"#,
927 )))
928 .unwrap(),
929 )
930 .await
931 .unwrap();
932
933 assert_invalid_request_response(response).await;
934 }
935
936 #[tokio::test]
937 async fn replace_multi_value_if_match_returns_400() {
938 let response = router()
939 .oneshot(
940 Request::builder()
941 .method("POST")
942 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
943 .header("authorization", format!("Bearer {TOKEN}"))
944 .header(axum::http::header::CONTENT_TYPE, "application/json")
945 .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
946 .body(Body::from(Bytes::from_static(
947 br#"{"old_string":"x","new_string":"y"}"#,
948 )))
949 .unwrap(),
950 )
951 .await
952 .unwrap();
953
954 assert_invalid_request_response(response).await;
955 }
956
957 #[tokio::test]
958 async fn replace_malformed_json_body_returns_400() {
959 let response = router()
960 .oneshot(
961 Request::builder()
962 .method("POST")
963 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
964 .header("authorization", format!("Bearer {TOKEN}"))
965 .header(axum::http::header::CONTENT_TYPE, "application/json")
966 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
967 .body(Body::from(Bytes::from_static(b"{invalid json")))
968 .unwrap(),
969 )
970 .await
971 .unwrap();
972
973 assert_invalid_request_response(response).await;
974 }
975
976 #[tokio::test]
977 async fn replace_missing_old_string_field_returns_400() {
978 let response = router()
979 .oneshot(
980 Request::builder()
981 .method("POST")
982 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
983 .header("authorization", format!("Bearer {TOKEN}"))
984 .header(axum::http::header::CONTENT_TYPE, "application/json")
985 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
986 .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
987 .unwrap(),
988 )
989 .await
990 .unwrap();
991
992 assert_invalid_request_response(response).await;
993 }
994
995 #[tokio::test]
996 async fn replace_empty_old_string_returns_400() {
997 let response = router()
998 .oneshot(
999 Request::builder()
1000 .method("POST")
1001 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
1002 .header("authorization", format!("Bearer {TOKEN}"))
1003 .header(axum::http::header::CONTENT_TYPE, "application/json")
1004 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
1005 .body(Body::from(Bytes::from_static(
1006 br#"{"old_string":"","new_string":"y"}"#,
1007 )))
1008 .unwrap(),
1009 )
1010 .await
1011 .unwrap();
1012
1013 assert_invalid_request_response(response).await;
1014 }
1015
1016 #[tokio::test]
1017 async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
1018 let router = router();
1019 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1020
1021 let response = post_replace(
1022 router.clone(),
1023 "hello.md",
1024 &etag,
1025 br#"{"old_string":"world","new_string":"planet"}"#,
1026 )
1027 .await;
1028
1029 assert_eq!(response.status(), StatusCode::OK);
1030 assert!(response.headers().get(axum::http::header::ETAG).is_some());
1031 assert_eq!(
1032 response.headers().get("content-location").unwrap(),
1033 "/api/v1/knowledgebases/notes/hello.md"
1034 );
1035 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1036 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1037 assert!(json["etag"].as_str().is_some());
1038 assert_eq!(json["match_count"], 1);
1039 assert_eq!(json["total_bytes"], 12);
1040 assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
1041 }
1042
1043 #[tokio::test]
1044 async fn replace_no_match_returns_422_no_match() {
1045 let router = router();
1046 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1047
1048 let response = post_replace(
1049 router.clone(),
1050 "hello.md",
1051 &etag,
1052 br#"{"old_string":"nonexistent","new_string":"x"}"#,
1053 )
1054 .await;
1055
1056 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1057 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1058 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1059 assert_eq!(json["error"], "no_match");
1060 assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
1061 }
1062
1063 #[tokio::test]
1064 async fn replace_ambiguous_returns_422_with_match_count() {
1065 let router = router();
1066 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1067
1068 let response = post_replace(
1069 router.clone(),
1070 "hello.md",
1071 &etag,
1072 br#"{"old_string":"a","new_string":"Z"}"#,
1073 )
1074 .await;
1075
1076 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1077 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1078 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1079 assert_eq!(json["error"], "ambiguous_match");
1080 assert_eq!(json["match_count"], 2);
1081 assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1082 }
1083
1084 #[tokio::test]
1085 async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1086 let router = router();
1087 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1088
1089 let response = post_replace(
1090 router.clone(),
1091 "hello.md",
1092 &etag,
1093 br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1094 )
1095 .await;
1096
1097 assert_eq!(response.status(), StatusCode::OK);
1098 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1099 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1100 assert_eq!(json["match_count"], 2);
1101 assert_eq!(json["total_bytes"], 5);
1102 assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1103 }
1104
1105 #[tokio::test]
1106 async fn replace_stale_etag_returns_412() {
1107 let router = router();
1108 put_object(router.clone(), "hello.md", b"hello world").await;
1109
1110 let response = post_replace(
1111 router,
1112 "hello.md",
1113 "\"stale\"",
1114 br#"{"old_string":"world","new_string":"planet"}"#,
1115 )
1116 .await;
1117
1118 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1119 }
1120
1121 #[tokio::test]
1122 async fn replace_post_splice_size_over_cap_returns_413() {
1123 let router = router_with_max_patchable_size(20);
1124 let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1125
1126 let response = post_replace(
1127 router,
1128 "hello.md",
1129 &etag,
1130 br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1131 )
1132 .await;
1133
1134 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1135 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1136 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1137 assert_eq!(json["error"], "payload_too_large");
1138 }
1139
1140 #[tokio::test]
1141 async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1142 let kb = KbSlug::try_new(KB).unwrap();
1143 let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1144 let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1145 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1146 indexer_tx
1147 .try_send(IndexEvent::Upsert {
1148 kb: kb.clone(),
1149 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1150 etag: "queued".to_string(),
1151 mtime: 0,
1152 })
1153 .unwrap();
1154 let router =
1155 router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1156
1157 let response = post_replace(
1158 router,
1159 "hello.md",
1160 &etag,
1161 br#"{"old_string":"world","new_string":"planet"}"#,
1162 )
1163 .await;
1164
1165 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1166 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1167 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1168 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1169 assert_eq!(json["error"], "backend_unavailable");
1170 }
1171
1172 #[tokio::test]
1173 async fn test_precondition_failed_body_shape_unchanged() {
1174 let resp = ApiErrorResponse {
1175 error: ApiError::PreconditionFailed,
1176 request_id: "req-1".into(),
1177 }
1178 .into_response();
1179
1180 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1181 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1182 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1183 let object = json.as_object().unwrap();
1184 assert_eq!(object.len(), 3);
1185 assert!(object.contains_key("error"));
1186 assert!(object.contains_key("message"));
1187 assert!(object.contains_key("request_id"));
1188 }
1189
1190 #[tokio::test]
1193 async fn test_precondition_failed_412() {
1194 let resp = ApiError::PreconditionFailed.into_response();
1195 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1196 }
1197
1198 #[tokio::test]
1199 async fn test_not_modified_304_empty_body() {
1200 let resp = ApiError::NotModified.into_response();
1201 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1202 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1203 assert!(body.is_empty(), "304 must have an empty body");
1204 }
1205
1206 #[tokio::test]
1207 async fn test_malformed_range_400() {
1208 let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1209 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1210 }
1211
1212 #[tokio::test]
1214 async fn test_range_not_satisfiable_416_content_range_header() {
1215 let resp = ApiError::RangeNotSatisfiable {
1216 complete_length: 100,
1217 }
1218 .into_response();
1219 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1220 let cr = resp
1221 .headers()
1222 .get("content-range")
1223 .expect("content-range header must be present on 416");
1224 assert_eq!(cr.to_str().unwrap(), "bytes */100");
1225 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1226 assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1227 }
1228
1229 #[tokio::test]
1232 async fn test_storage_range_not_satisfiable_416_content_range_header() {
1233 let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1234 complete_length: 42,
1235 })
1236 .into_response();
1237 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1238 let cr = resp
1239 .headers()
1240 .get("content-range")
1241 .expect("content-range header must be present on 416");
1242 assert_eq!(cr.to_str().unwrap(), "bytes */42");
1243 }
1244
1245 #[tokio::test]
1247 async fn test_storage_not_modified_304_empty_body() {
1248 let resp = ApiError::Storage(StorageError::NotModified).into_response();
1249 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1250 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1251 assert!(body.is_empty(), "304 must have an empty body");
1252 }
1253
1254 #[test]
1256 fn test_from_storage_error_not_modified() {
1257 let api_err = ApiError::from(StorageError::NotModified);
1258 assert!(matches!(api_err, ApiError::NotModified));
1259 }
1260
1261 #[test]
1263 fn test_from_storage_error_precondition_failed() {
1264 let api_err = ApiError::from(StorageError::PreconditionFailed);
1265 assert!(matches!(api_err, ApiError::PreconditionFailed));
1266 }
1267
1268 #[test]
1270 fn test_from_storage_error_range_not_satisfiable() {
1271 let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1272 complete_length: 999,
1273 });
1274 assert!(
1275 matches!(
1276 api_err,
1277 ApiError::RangeNotSatisfiable {
1278 complete_length: 999
1279 }
1280 ),
1281 "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1282 );
1283 }
1284
1285 #[test]
1287 fn test_from_storage_error_other_wrapped() {
1288 let api_err = ApiError::from(StorageError::NotFound {
1289 key: "foo".to_string(),
1290 });
1291 assert!(matches!(api_err, ApiError::Storage(_)));
1292 }
1293
1294 #[test]
1298 fn test_from_core_error_bucket_not_found_is_sanitised() {
1299 let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1300 bucket: "nt-default-notes".to_string(),
1301 }));
1302 assert!(
1303 matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1304 "{api_err:?}"
1305 );
1306 let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1308 key: "a.md".to_string(),
1309 }));
1310 assert!(matches!(
1311 api_err,
1312 ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1313 ));
1314 }
1315
1316 #[tokio::test]
1320 async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1321 for error in [
1322 ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1323 bucket: "nt-acme-notes".to_string(),
1324 })),
1325 ApiError::Storage(StorageError::BucketNotFound {
1326 bucket: "nt-acme-notes".to_string(),
1327 }),
1328 ] {
1329 let resp = ApiErrorResponse {
1330 error,
1331 request_id: "req-1".into(),
1332 }
1333 .into_response();
1334 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1335 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1336 let text = String::from_utf8(body.to_vec()).unwrap();
1337 let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1338 assert_eq!(json["error"], "not_found");
1339 assert_eq!(json["message"], "not found: knowledge base storage");
1340 assert!(!text.contains("nt-"), "{text}");
1341 assert!(!text.contains("bucket"), "{text}");
1342 }
1343 }
1344
1345 mod line_range_error {
1346 use super::*;
1347 use axum::body::Body;
1348 use axum::http::Request;
1349 use bytes::Bytes;
1350 use notedthat_core::KbSlug;
1351 use std::collections::BTreeMap;
1352 use std::sync::Arc;
1353 use tower::util::ServiceExt;
1354
1355 const KB: &str = "notes";
1356 const TOKEN: &str = "test-token-abc";
1357
1358 fn twenty_line_markdown() -> String {
1359 let mut body = String::new();
1360 for line in 1..=20 {
1361 std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1362 }
1363 body
1364 }
1365
1366 fn router() -> axum::Router {
1367 let kb = KbSlug::try_new(KB).unwrap();
1368 let mut kbs = BTreeMap::new();
1369 kbs.insert(KB.to_string(), kb);
1370 let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1371 tokio::spawn(async move { while rx.recv().await.is_some() {} });
1372
1373 crate::router::build_router(crate::state::AppState {
1374 storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1375 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1376 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1377 declared_kbs: Arc::new(kbs),
1378 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1379 max_body_size: 16 * 1024 * 1024,
1380 max_patchable_size: 16 * 1024 * 1024,
1381 indexer_tx,
1382 searcher: Arc::new(crate::testing::NoopSearcher),
1383 events: None,
1384 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1385 readiness: crate::testing::ready_receiver(),
1386 reconcile: None,
1387 })
1388 }
1389
1390 async fn put_ranges_md(router: axum::Router) {
1391 let response = router
1392 .oneshot(
1393 Request::builder()
1394 .method("PUT")
1395 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1396 .header("authorization", format!("Bearer {TOKEN}"))
1397 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1398 .body(Body::from(Bytes::from(twenty_line_markdown())))
1399 .unwrap(),
1400 )
1401 .await
1402 .unwrap();
1403
1404 assert_eq!(response.status(), StatusCode::CREATED);
1405 }
1406
1407 #[tokio::test]
1408 async fn malformed_line_range_returns_json_400() {
1409 let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1410
1411 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1412 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1413 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1414 assert_eq!(json["error"], "malformed_range");
1415 }
1416
1417 #[tokio::test]
1418 async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1419 let response = ApiError::LineRangeNotSatisfiable {
1420 line_total: 20,
1421 byte_total: 100,
1422 }
1423 .into_response();
1424
1425 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1426 assert_eq!(
1427 response.headers().get("content-range").unwrap(),
1428 "lines */20"
1429 );
1430 assert_eq!(
1431 response.headers().get("x-content-range-bytes").unwrap(),
1432 "*/100"
1433 );
1434 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1435 assert!(body.is_empty());
1436 }
1437
1438 #[tokio::test]
1439 async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1440 let router = router();
1441 put_ranges_md(router.clone()).await;
1442
1443 let response = router
1444 .oneshot(
1445 Request::builder()
1446 .method("GET")
1447 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1448 .header("authorization", format!("Bearer {TOKEN}"))
1449 .header(axum::http::header::RANGE, "lines=100-200")
1450 .body(Body::empty())
1451 .unwrap(),
1452 )
1453 .await
1454 .unwrap();
1455
1456 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1457 assert_eq!(
1458 response.headers().get("content-range").unwrap(),
1459 "lines */20"
1460 );
1461 assert_eq!(
1462 response.headers().get("x-content-range-bytes").unwrap(),
1463 "*/160"
1464 );
1465 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1466 assert!(body.is_empty());
1467 }
1468
1469 #[tokio::test]
1470 async fn byte_range_not_satisfiable_omits_line_byte_header() {
1471 let response = ApiError::RangeNotSatisfiable {
1472 complete_length: 100,
1473 }
1474 .into_response();
1475
1476 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1477 assert_eq!(
1478 response.headers().get("content-range").unwrap(),
1479 "bytes */100"
1480 );
1481 assert!(response.headers().get("x-content-range-bytes").is_none());
1482 }
1483
1484 #[test]
1485 fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1486 let error = ApiError::from(WriteError::PatchLineOutOfRange {
1487 first: 100,
1488 last: 200,
1489 total_lines: 20,
1490 total_bytes: 100,
1491 });
1492
1493 assert!(matches!(
1494 error,
1495 ApiError::LineRangeNotSatisfiable {
1496 line_total: 20,
1497 byte_total: 100
1498 }
1499 ));
1500 }
1501 }
1502}