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