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