1use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use thiserror::Error;
12use utoipa::ToSchema;
13
14pub type Result<T> = std::result::Result<T, Error>;
16
17#[derive(Debug, Error)]
22pub enum Error {
23 #[error("configuration error: {message}")]
25 Config {
26 message: String,
28 key: Option<String>,
30 },
31
32 #[error("database error: {0}")]
34 Database(#[from] DatabaseError),
35
36 #[error("database error: {0}")]
38 Sqlx(#[from] sqlx::Error),
39
40 #[error("NNTP error: {0}")]
42 Nntp(String),
43
44 #[error("download error: {0}")]
46 Download(#[from] DownloadError),
47
48 #[error("post-processing error: {0}")]
50 PostProcess(#[from] PostProcessError),
51
52 #[error("invalid NZB: {0}")]
54 InvalidNzb(String),
55
56 #[error("I/O error: {0}")]
58 Io(#[from] std::io::Error),
59
60 #[error("download not found: {0}")]
62 NotFound(String),
63
64 #[error("shutdown in progress: not accepting new downloads")]
66 ShuttingDown,
67
68 #[error("network error: {0}")]
70 Network(#[from] reqwest::Error),
71
72 #[error("serialization error: {0}")]
74 Serialization(#[from] serde_json::Error),
75
76 #[error("API server error: {0}")]
78 ApiServerError(String),
79
80 #[error("folder watch error: {0}")]
82 FolderWatch(String),
83
84 #[error("duplicate download: {0}")]
86 Duplicate(String),
87
88 #[error("insufficient disk space: need {required} bytes, have {available} bytes")]
90 InsufficientSpace {
91 required: u64,
93 available: u64,
95 },
96
97 #[error("failed to check disk space: {0}")]
99 DiskSpaceCheckFailed(String),
100
101 #[error("external tool error: {0}")]
103 ExternalTool(String),
104
105 #[error("not supported: {0}")]
107 NotSupported(String),
108
109 #[error("{0}")]
111 Other(String),
112}
113
114#[derive(Debug, Error)]
116pub enum DatabaseError {
117 #[error("failed to connect to database: {0}")]
119 ConnectionFailed(String),
120
121 #[error("failed to run migrations: {0}")]
123 MigrationFailed(String),
124
125 #[error("query failed: {0}")]
127 QueryFailed(String),
128
129 #[error("record not found: {0}")]
131 NotFound(String),
132
133 #[error("constraint violation: {0}")]
135 ConstraintViolation(String),
136}
137
138#[derive(Debug, Error)]
140pub enum DownloadError {
141 #[error("download {id} not found")]
143 NotFound {
144 id: i64,
146 },
147
148 #[error("download {id} files not found at {path}")]
150 FilesNotFound {
151 id: i64,
153 path: PathBuf,
155 },
156
157 #[error("download {id} is already {state}")]
159 AlreadyInState {
160 id: i64,
162 state: String,
164 },
165
166 #[error("cannot {operation} download {id} in state {current_state}")]
168 InvalidState {
169 id: i64,
171 operation: String,
173 current_state: String,
175 },
176
177 #[error("insufficient disk space: need {required} bytes, have {available} bytes")]
179 InsufficientSpace {
180 required: u64,
182 available: u64,
184 },
185}
186
187#[derive(Debug, Error)]
189pub enum PostProcessError {
190 #[error("PAR2 verification failed for download {id}: {reason}")]
192 VerificationFailed {
193 id: i64,
195 reason: String,
197 },
198
199 #[error("PAR2 repair failed for download {id}: {reason}")]
201 RepairFailed {
202 id: i64,
204 reason: String,
206 },
207
208 #[error("extraction failed for {archive}: {reason}")]
210 ExtractionFailed {
211 archive: PathBuf,
213 reason: String,
215 },
216
217 #[error("wrong password for encrypted archive {archive}")]
219 WrongPassword {
220 archive: PathBuf,
222 },
223
224 #[error("all {count} passwords failed for archive {archive}")]
226 AllPasswordsFailed {
227 archive: PathBuf,
229 count: usize,
231 },
232
233 #[error("no passwords available for encrypted archive {archive}")]
235 NoPasswordsAvailable {
236 archive: PathBuf,
238 },
239
240 #[error("failed to move {source_path} to {dest_path}: {reason}")]
242 MoveFailed {
243 source_path: PathBuf,
245 dest_path: PathBuf,
247 reason: String,
249 },
250
251 #[error("file collision at {path}: {reason}")]
253 FileCollision {
254 path: PathBuf,
256 reason: String,
258 },
259
260 #[error("cleanup failed for download {id}: {reason}")]
262 CleanupFailed {
263 id: i64,
265 reason: String,
267 },
268
269 #[error("invalid path {path}: {reason}")]
271 InvalidPath {
272 path: PathBuf,
274 reason: String,
276 },
277
278 #[error("DirectUnpack failed for download {id}: {reason}")]
280 DirectUnpackFailed {
281 id: i64,
283 reason: String,
285 },
286
287 #[error("DirectRename failed for download {id}: {reason}")]
289 DirectRenameFailed {
290 id: i64,
292 reason: String,
294 },
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
317pub struct ApiError {
318 pub error: ErrorDetail,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
324pub struct ErrorDetail {
325 pub code: String,
329
330 pub message: String,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
339 pub details: Option<serde_json::Value>,
340}
341
342impl ApiError {
343 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
345 Self {
346 error: ErrorDetail {
347 code: code.into(),
348 message: message.into(),
349 details: None,
350 },
351 }
352 }
353
354 pub fn with_details(
356 code: impl Into<String>,
357 message: impl Into<String>,
358 details: serde_json::Value,
359 ) -> Self {
360 Self {
361 error: ErrorDetail {
362 code: code.into(),
363 message: message.into(),
364 details: Some(details),
365 },
366 }
367 }
368
369 pub fn not_found(resource: impl Into<String>) -> Self {
371 Self::new("not_found", format!("{} not found", resource.into()))
372 }
373
374 pub fn validation(message: impl Into<String>) -> Self {
376 Self::new("validation_error", message)
377 }
378
379 pub fn conflict(message: impl Into<String>) -> Self {
381 Self::new("conflict", message)
382 }
383
384 pub fn internal(message: impl Into<String>) -> Self {
386 Self::new("internal_error", message)
387 }
388
389 pub fn unauthorized(message: impl Into<String>) -> Self {
391 Self::new("unauthorized", message)
392 }
393
394 pub fn service_unavailable(message: impl Into<String>) -> Self {
396 Self::new("service_unavailable", message)
397 }
398}
399
400pub trait ToHttpStatus {
404 fn status_code(&self) -> u16;
406
407 fn error_code(&self) -> &str;
409}
410
411impl ToHttpStatus for Error {
412 fn status_code(&self) -> u16 {
413 match self {
414 Error::Config { .. } => 400,
416 Error::InvalidNzb(_) => 422, Error::Duplicate(_) => 409, Error::NotFound(_) => 404,
421 Error::Download(DownloadError::NotFound { .. }) => 404,
422 Error::Download(DownloadError::FilesNotFound { .. }) => 404,
423
424 Error::Download(DownloadError::AlreadyInState { .. }) => 409,
426 Error::Download(DownloadError::InvalidState { .. }) => 409,
427
428 Error::PostProcess(_) => 422,
430 Error::Download(DownloadError::InsufficientSpace { .. }) => 422,
431 Error::InsufficientSpace { .. } => 422,
432
433 Error::Database(_) => 500,
435 Error::Sqlx(_) => 500,
436 Error::Io(_) => 500,
437 Error::ApiServerError(_) => 500,
438 Error::FolderWatch(_) => 500,
439 Error::DiskSpaceCheckFailed(_) => 500,
440 Error::Other(_) => 500,
441
442 Error::Nntp(_) => 502,
444 Error::Network(_) => 502,
445
446 Error::ShuttingDown => 503,
448 Error::ExternalTool(_) => 503,
449
450 Error::NotSupported(_) => 501,
452
453 Error::Serialization(_) => 500,
455 }
456 }
457
458 fn error_code(&self) -> &str {
459 match self {
460 Error::Config { .. } => "config_error",
461 Error::Database(_) => "database_error",
462 Error::Sqlx(_) => "database_error",
463 Error::Nntp(_) => "nntp_error",
464 Error::Download(e) => match e {
465 DownloadError::NotFound { .. } => "download_not_found",
466 DownloadError::FilesNotFound { .. } => "files_not_found",
467 DownloadError::AlreadyInState { .. } => "already_in_state",
468 DownloadError::InvalidState { .. } => "invalid_state",
469 DownloadError::InsufficientSpace { .. } => "insufficient_space",
470 },
471 Error::PostProcess(e) => match e {
472 PostProcessError::VerificationFailed { .. } => "verification_failed",
473 PostProcessError::RepairFailed { .. } => "repair_failed",
474 PostProcessError::ExtractionFailed { .. } => "extraction_failed",
475 PostProcessError::WrongPassword { .. } => "wrong_password",
476 PostProcessError::AllPasswordsFailed { .. } => "all_passwords_failed",
477 PostProcessError::NoPasswordsAvailable { .. } => "no_passwords_available",
478 PostProcessError::MoveFailed { .. } => "move_failed",
479 PostProcessError::FileCollision { .. } => "file_collision",
480 PostProcessError::CleanupFailed { .. } => "cleanup_failed",
481 PostProcessError::InvalidPath { .. } => "invalid_path",
482 PostProcessError::DirectUnpackFailed { .. } => "direct_unpack_failed",
483 PostProcessError::DirectRenameFailed { .. } => "direct_rename_failed",
484 },
485 Error::InvalidNzb(_) => "invalid_nzb",
486 Error::Io(_) => "io_error",
487 Error::NotFound(_) => "not_found",
488 Error::ShuttingDown => "shutting_down",
489 Error::Network(_) => "network_error",
490 Error::Serialization(_) => "serialization_error",
491 Error::ApiServerError(_) => "api_server_error",
492 Error::FolderWatch(_) => "folder_watch_error",
493 Error::Duplicate(_) => "duplicate",
494 Error::InsufficientSpace { .. } => "insufficient_space",
495 Error::DiskSpaceCheckFailed(_) => "disk_space_check_failed",
496 Error::ExternalTool(_) => "external_tool_error",
497 Error::NotSupported(_) => "not_supported",
498 Error::Other(_) => "internal_error",
499 }
500 }
501}
502
503impl From<Error> for ApiError {
504 fn from(error: Error) -> Self {
505 let code = error.error_code().to_string();
506 let message = error.to_string();
507
508 let details = match &error {
510 Error::Download(DownloadError::NotFound { id }) => Some(serde_json::json!({
511 "download_id": id,
512 })),
513 Error::Download(DownloadError::FilesNotFound { id, path }) => Some(serde_json::json!({
514 "download_id": id,
515 "path": path,
516 })),
517 Error::Download(DownloadError::AlreadyInState { id, state }) => {
518 Some(serde_json::json!({
519 "download_id": id,
520 "state": state,
521 }))
522 }
523 Error::Download(DownloadError::InvalidState {
524 id,
525 operation,
526 current_state,
527 }) => Some(serde_json::json!({
528 "download_id": id,
529 "operation": operation,
530 "current_state": current_state,
531 })),
532 Error::Download(DownloadError::InsufficientSpace {
533 required,
534 available,
535 }) => Some(serde_json::json!({
536 "required_bytes": required,
537 "available_bytes": available,
538 })),
539 Error::InsufficientSpace {
540 required,
541 available,
542 } => Some(serde_json::json!({
543 "required_bytes": required,
544 "available_bytes": available,
545 })),
546 Error::PostProcess(PostProcessError::FileCollision { path, .. }) => {
547 Some(serde_json::json!({
548 "path": path,
549 }))
550 }
551 Error::PostProcess(PostProcessError::WrongPassword { archive }) => {
552 Some(serde_json::json!({
553 "archive": archive,
554 }))
555 }
556 Error::PostProcess(PostProcessError::AllPasswordsFailed { archive, count }) => {
557 Some(serde_json::json!({
558 "archive": archive,
559 "password_count": count,
560 }))
561 }
562 _ => None,
563 };
564
565 ApiError {
566 error: ErrorDetail {
567 code,
568 message,
569 details,
570 },
571 }
572 }
573}
574
575#[allow(clippy::unwrap_used, clippy::expect_used)]
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn all_error_variants() -> Vec<(Error, u16, &'static str)> {
587 vec![
588 (
590 Error::Config {
591 message: "bad value".into(),
592 key: Some("download_dir".into()),
593 },
594 400,
595 "config_error",
596 ),
597 (
598 Error::InvalidNzb("missing segments".into()),
599 422,
600 "invalid_nzb",
601 ),
602 (Error::Duplicate("already queued".into()), 409, "duplicate"),
603 (Error::NotFound("download 99".into()), 404, "not_found"),
604 (
605 Error::Database(DatabaseError::QueryFailed("timeout".into())),
606 500,
607 "database_error",
608 ),
609 (
610 Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")),
611 500,
612 "io_error",
613 ),
614 (
615 Error::ApiServerError("bind failed".into()),
616 500,
617 "api_server_error",
618 ),
619 (
620 Error::FolderWatch("inotify error".into()),
621 500,
622 "folder_watch_error",
623 ),
624 (
625 Error::DiskSpaceCheckFailed("statvfs failed".into()),
626 500,
627 "disk_space_check_failed",
628 ),
629 (Error::Other("unknown".into()), 500, "internal_error"),
630 (Error::Nntp("connection reset".into()), 502, "nntp_error"),
631 (Error::ShuttingDown, 503, "shutting_down"),
632 (
633 Error::ExternalTool("par2 not found".into()),
634 503,
635 "external_tool_error",
636 ),
637 (
638 Error::NotSupported("par2 binary missing".into()),
639 501,
640 "not_supported",
641 ),
642 (
643 Error::InsufficientSpace {
644 required: 1_000_000,
645 available: 500,
646 },
647 422,
648 "insufficient_space",
649 ),
650 (
652 Error::Download(DownloadError::NotFound { id: 42 }),
653 404,
654 "download_not_found",
655 ),
656 (
657 Error::Download(DownloadError::FilesNotFound {
658 id: 42,
659 path: PathBuf::from("/tmp/dl"),
660 }),
661 404,
662 "files_not_found",
663 ),
664 (
665 Error::Download(DownloadError::AlreadyInState {
666 id: 42,
667 state: "paused".into(),
668 }),
669 409,
670 "already_in_state",
671 ),
672 (
673 Error::Download(DownloadError::InvalidState {
674 id: 42,
675 operation: "pause".into(),
676 current_state: "completed".into(),
677 }),
678 409,
679 "invalid_state",
680 ),
681 (
682 Error::Download(DownloadError::InsufficientSpace {
683 required: 2_000_000,
684 available: 1_000,
685 }),
686 422,
687 "insufficient_space",
688 ),
689 (
691 Error::PostProcess(PostProcessError::VerificationFailed {
692 id: 1,
693 reason: "corrupt".into(),
694 }),
695 422,
696 "verification_failed",
697 ),
698 (
699 Error::PostProcess(PostProcessError::RepairFailed {
700 id: 1,
701 reason: "too many missing".into(),
702 }),
703 422,
704 "repair_failed",
705 ),
706 (
707 Error::PostProcess(PostProcessError::ExtractionFailed {
708 archive: PathBuf::from("test.rar"),
709 reason: "crc error".into(),
710 }),
711 422,
712 "extraction_failed",
713 ),
714 (
715 Error::PostProcess(PostProcessError::WrongPassword {
716 archive: PathBuf::from("secret.rar"),
717 }),
718 422,
719 "wrong_password",
720 ),
721 (
722 Error::PostProcess(PostProcessError::AllPasswordsFailed {
723 archive: PathBuf::from("secret.rar"),
724 count: 5,
725 }),
726 422,
727 "all_passwords_failed",
728 ),
729 (
730 Error::PostProcess(PostProcessError::NoPasswordsAvailable {
731 archive: PathBuf::from("secret.rar"),
732 }),
733 422,
734 "no_passwords_available",
735 ),
736 (
737 Error::PostProcess(PostProcessError::MoveFailed {
738 source_path: PathBuf::from("/tmp/a"),
739 dest_path: PathBuf::from("/tmp/b"),
740 reason: "permission denied".into(),
741 }),
742 422,
743 "move_failed",
744 ),
745 (
746 Error::PostProcess(PostProcessError::FileCollision {
747 path: PathBuf::from("/dest/file.mkv"),
748 reason: "file already exists".into(),
749 }),
750 422,
751 "file_collision",
752 ),
753 (
754 Error::PostProcess(PostProcessError::CleanupFailed {
755 id: 1,
756 reason: "directory not empty".into(),
757 }),
758 422,
759 "cleanup_failed",
760 ),
761 (
762 Error::PostProcess(PostProcessError::InvalidPath {
763 path: PathBuf::from("../escape"),
764 reason: "path traversal".into(),
765 }),
766 422,
767 "invalid_path",
768 ),
769 (
770 Error::PostProcess(PostProcessError::DirectUnpackFailed {
771 id: 1,
772 reason: "extraction error".into(),
773 }),
774 422,
775 "direct_unpack_failed",
776 ),
777 (
778 Error::PostProcess(PostProcessError::DirectRenameFailed {
779 id: 1,
780 reason: "rename error".into(),
781 }),
782 422,
783 "direct_rename_failed",
784 ),
785 ]
786 }
787
788 #[test]
793 fn every_variant_maps_to_expected_status_code() {
794 for (error, expected_status, expected_code) in all_error_variants() {
795 let actual_status = error.status_code();
796 assert_eq!(
797 actual_status, expected_status,
798 "Error variant with error_code={expected_code} returned status {actual_status}, expected {expected_status}"
799 );
800 }
801 }
802
803 #[test]
808 fn every_variant_maps_to_expected_error_code() {
809 for (error, expected_status, expected_code) in all_error_variants() {
810 let actual_code = error.error_code();
811 assert_eq!(
812 actual_code, expected_code,
813 "Error variant with expected status={expected_status} returned error_code={actual_code}, expected {expected_code}"
814 );
815 }
816 }
817
818 #[test]
824 fn config_error_is_400_not_500() {
825 let err = Error::Config {
826 message: "bad".into(),
827 key: None,
828 };
829 assert_eq!(err.status_code(), 400);
830 }
831
832 #[test]
833 fn invalid_nzb_is_422_not_400() {
834 let err = Error::InvalidNzb("bad xml".into());
835 assert_eq!(err.status_code(), 422);
836 }
837
838 #[test]
839 fn duplicate_is_409_conflict() {
840 let err = Error::Duplicate("same hash".into());
841 assert_eq!(err.status_code(), 409);
842 }
843
844 #[test]
845 fn download_not_found_is_404() {
846 let err = Error::Download(DownloadError::NotFound { id: 1 });
847 assert_eq!(err.status_code(), 404);
848 }
849
850 #[test]
851 fn download_files_not_found_is_404() {
852 let err = Error::Download(DownloadError::FilesNotFound {
853 id: 1,
854 path: PathBuf::from("/tmp"),
855 });
856 assert_eq!(err.status_code(), 404);
857 }
858
859 #[test]
860 fn download_already_in_state_is_409() {
861 let err = Error::Download(DownloadError::AlreadyInState {
862 id: 1,
863 state: "paused".into(),
864 });
865 assert_eq!(err.status_code(), 409);
866 }
867
868 #[test]
869 fn download_invalid_state_is_409() {
870 let err = Error::Download(DownloadError::InvalidState {
871 id: 1,
872 operation: "resume".into(),
873 current_state: "completed".into(),
874 });
875 assert_eq!(err.status_code(), 409);
876 }
877
878 #[test]
879 fn nntp_error_is_502_bad_gateway() {
880 let err = Error::Nntp("connection refused".into());
881 assert_eq!(err.status_code(), 502);
882 }
883
884 #[test]
885 fn shutting_down_is_503() {
886 assert_eq!(Error::ShuttingDown.status_code(), 503);
887 }
888
889 #[test]
890 fn not_supported_is_501() {
891 let err = Error::NotSupported("feature X".into());
892 assert_eq!(err.status_code(), 501);
893 }
894
895 #[test]
900 fn api_error_from_download_not_found_has_download_id() {
901 let err = Error::Download(DownloadError::NotFound { id: 42 });
902 let api: ApiError = err.into();
903
904 assert_eq!(api.error.code, "download_not_found");
905 let details = api.error.details.expect("should have details");
906 assert_eq!(details["download_id"], 42);
907 }
908
909 #[test]
910 fn api_error_from_download_files_not_found_has_id_and_path() {
911 let err = Error::Download(DownloadError::FilesNotFound {
912 id: 7,
913 path: PathBuf::from("/data/downloads/test"),
914 });
915 let api: ApiError = err.into();
916
917 assert_eq!(api.error.code, "files_not_found");
918 let details = api.error.details.expect("should have details");
919 assert_eq!(details["download_id"], 7);
920 assert_eq!(details["path"], "/data/downloads/test");
921 }
922
923 #[test]
924 fn api_error_from_already_in_state_has_id_and_state() {
925 let err = Error::Download(DownloadError::AlreadyInState {
926 id: 10,
927 state: "paused".into(),
928 });
929 let api: ApiError = err.into();
930
931 assert_eq!(api.error.code, "already_in_state");
932 let details = api.error.details.expect("should have details");
933 assert_eq!(details["download_id"], 10);
934 assert_eq!(details["state"], "paused");
935 }
936
937 #[test]
938 fn api_error_from_invalid_state_has_operation_and_current_state() {
939 let err = Error::Download(DownloadError::InvalidState {
940 id: 3,
941 operation: "pause".into(),
942 current_state: "completed".into(),
943 });
944 let api: ApiError = err.into();
945
946 assert_eq!(api.error.code, "invalid_state");
947 let details = api.error.details.expect("should have details");
948 assert_eq!(details["download_id"], 3);
949 assert_eq!(details["operation"], "pause");
950 assert_eq!(details["current_state"], "completed");
951 }
952
953 #[test]
954 fn api_error_from_download_insufficient_space_has_byte_counts() {
955 let err = Error::Download(DownloadError::InsufficientSpace {
956 required: 5_000_000,
957 available: 1_000,
958 });
959 let api: ApiError = err.into();
960
961 assert_eq!(api.error.code, "insufficient_space");
962 let details = api.error.details.expect("should have details");
963 assert_eq!(details["required_bytes"], 5_000_000_u64);
964 assert_eq!(details["available_bytes"], 1_000_u64);
965 }
966
967 #[test]
968 fn api_error_from_top_level_insufficient_space_has_byte_counts() {
969 let err = Error::InsufficientSpace {
970 required: 9_999_999,
971 available: 42,
972 };
973 let api: ApiError = err.into();
974
975 assert_eq!(api.error.code, "insufficient_space");
976 let details = api.error.details.expect("should have details");
977 assert_eq!(details["required_bytes"], 9_999_999_u64);
978 assert_eq!(details["available_bytes"], 42_u64);
979 }
980
981 #[test]
982 fn api_error_from_all_passwords_failed_has_archive_and_count() {
983 let err = Error::PostProcess(PostProcessError::AllPasswordsFailed {
984 archive: PathBuf::from("/tmp/secret.rar"),
985 count: 15,
986 });
987 let api: ApiError = err.into();
988
989 assert_eq!(api.error.code, "all_passwords_failed");
990 let details = api.error.details.expect("should have details");
991 assert_eq!(details["archive"], "/tmp/secret.rar");
992 assert_eq!(details["password_count"], 15);
993 }
994
995 #[test]
996 fn api_error_from_wrong_password_has_archive() {
997 let err = Error::PostProcess(PostProcessError::WrongPassword {
998 archive: PathBuf::from("/tmp/encrypted.rar"),
999 });
1000 let api: ApiError = err.into();
1001
1002 assert_eq!(api.error.code, "wrong_password");
1003 let details = api.error.details.expect("should have details");
1004 assert_eq!(details["archive"], "/tmp/encrypted.rar");
1005 }
1006
1007 #[test]
1008 fn api_error_from_file_collision_has_path() {
1009 let err = Error::PostProcess(PostProcessError::FileCollision {
1010 path: PathBuf::from("/dest/movie.mkv"),
1011 reason: "file already exists".into(),
1012 });
1013 let api: ApiError = err.into();
1014
1015 assert_eq!(api.error.code, "file_collision");
1016 let details = api.error.details.expect("should have details");
1017 assert_eq!(details["path"], "/dest/movie.mkv");
1018 }
1019
1020 #[test]
1025 fn api_error_from_io_has_no_details() {
1026 let err = Error::Io(std::io::Error::other("disk fail"));
1027 let api: ApiError = err.into();
1028
1029 assert_eq!(api.error.code, "io_error");
1030 assert!(
1031 api.error.details.is_none(),
1032 "Io errors should not have structured details"
1033 );
1034 }
1035
1036 #[test]
1037 fn api_error_from_nntp_has_no_details() {
1038 let err = Error::Nntp("timeout".into());
1039 let api: ApiError = err.into();
1040
1041 assert_eq!(api.error.code, "nntp_error");
1042 assert!(
1043 api.error.details.is_none(),
1044 "NNTP errors should not have structured details"
1045 );
1046 }
1047
1048 #[test]
1049 fn api_error_from_shutting_down_has_no_details() {
1050 let api: ApiError = Error::ShuttingDown.into();
1051
1052 assert_eq!(api.error.code, "shutting_down");
1053 assert!(
1054 api.error.details.is_none(),
1055 "ShuttingDown should not have structured details"
1056 );
1057 }
1058
1059 #[test]
1060 fn api_error_from_config_has_no_details() {
1061 let err = Error::Config {
1062 message: "invalid port".into(),
1063 key: Some("server.port".into()),
1064 };
1065 let api: ApiError = err.into();
1066
1067 assert_eq!(api.error.code, "config_error");
1068 assert!(
1069 api.error.details.is_none(),
1070 "Config errors should not have structured details"
1071 );
1072 }
1073
1074 #[test]
1075 fn api_error_from_database_has_no_details() {
1076 let err = Error::Database(DatabaseError::ConnectionFailed("refused".into()));
1077 let api: ApiError = err.into();
1078
1079 assert_eq!(api.error.code, "database_error");
1080 assert!(
1081 api.error.details.is_none(),
1082 "Database errors should not have structured details"
1083 );
1084 }
1085
1086 #[test]
1087 fn api_error_from_not_found_string_has_no_details() {
1088 let err = Error::NotFound("download 99".into());
1089 let api: ApiError = err.into();
1090
1091 assert_eq!(api.error.code, "not_found");
1092 assert!(
1093 api.error.details.is_none(),
1094 "Top-level NotFound(String) should not have structured details"
1095 );
1096 }
1097
1098 #[test]
1099 fn api_error_from_other_has_no_details() {
1100 let err = Error::Other("something went wrong".into());
1101 let api: ApiError = err.into();
1102
1103 assert_eq!(api.error.code, "internal_error");
1104 assert!(api.error.details.is_none());
1105 }
1106
1107 #[test]
1108 fn api_error_from_external_tool_has_no_details() {
1109 let err = Error::ExternalTool("unrar not found".into());
1110 let api: ApiError = err.into();
1111
1112 assert_eq!(api.error.code, "external_tool_error");
1113 assert!(api.error.details.is_none());
1114 }
1115
1116 #[test]
1117 fn api_error_from_postprocess_without_details_has_none() {
1118 let variants: Vec<Error> = vec![
1120 Error::PostProcess(PostProcessError::VerificationFailed {
1121 id: 1,
1122 reason: "corrupt".into(),
1123 }),
1124 Error::PostProcess(PostProcessError::RepairFailed {
1125 id: 1,
1126 reason: "too damaged".into(),
1127 }),
1128 Error::PostProcess(PostProcessError::ExtractionFailed {
1129 archive: PathBuf::from("test.rar"),
1130 reason: "crc error".into(),
1131 }),
1132 Error::PostProcess(PostProcessError::NoPasswordsAvailable {
1133 archive: PathBuf::from("secret.rar"),
1134 }),
1135 Error::PostProcess(PostProcessError::MoveFailed {
1136 source_path: PathBuf::from("/a"),
1137 dest_path: PathBuf::from("/b"),
1138 reason: "denied".into(),
1139 }),
1140 Error::PostProcess(PostProcessError::CleanupFailed {
1141 id: 1,
1142 reason: "locked".into(),
1143 }),
1144 Error::PostProcess(PostProcessError::InvalidPath {
1145 path: PathBuf::from("../bad"),
1146 reason: "traversal".into(),
1147 }),
1148 ];
1149
1150 for err in variants {
1151 let code = err.error_code().to_string();
1152 let api: ApiError = err.into();
1153 assert!(
1154 api.error.details.is_none(),
1155 "PostProcessError with code={code} should not have structured details"
1156 );
1157 }
1158 }
1159
1160 #[test]
1165 fn api_error_not_found_factory() {
1166 let api = ApiError::not_found("Download 123");
1167
1168 assert_eq!(api.error.code, "not_found");
1169 assert_eq!(api.error.message, "Download 123 not found");
1170 assert!(api.error.details.is_none());
1171 }
1172
1173 #[test]
1174 fn api_error_validation_factory() {
1175 let api = ApiError::validation("name is required");
1176
1177 assert_eq!(api.error.code, "validation_error");
1178 assert_eq!(api.error.message, "name is required");
1179 assert!(api.error.details.is_none());
1180 }
1181
1182 #[test]
1183 fn api_error_conflict_factory() {
1184 let api = ApiError::conflict("download already exists");
1185
1186 assert_eq!(api.error.code, "conflict");
1187 assert_eq!(api.error.message, "download already exists");
1188 assert!(api.error.details.is_none());
1189 }
1190
1191 #[test]
1192 fn api_error_internal_factory() {
1193 let api = ApiError::internal("unexpected failure");
1194
1195 assert_eq!(api.error.code, "internal_error");
1196 assert_eq!(api.error.message, "unexpected failure");
1197 assert!(api.error.details.is_none());
1198 }
1199
1200 #[test]
1201 fn api_error_unauthorized_factory() {
1202 let api = ApiError::unauthorized("invalid token");
1203
1204 assert_eq!(api.error.code, "unauthorized");
1205 assert_eq!(api.error.message, "invalid token");
1206 assert!(api.error.details.is_none());
1207 }
1208
1209 #[test]
1210 fn api_error_service_unavailable_factory() {
1211 let api = ApiError::service_unavailable("server overloaded");
1212
1213 assert_eq!(api.error.code, "service_unavailable");
1214 assert_eq!(api.error.message, "server overloaded");
1215 assert!(api.error.details.is_none());
1216 }
1217
1218 #[test]
1223 fn with_details_preserves_json_object() {
1224 let details = serde_json::json!({
1225 "download_id": 42,
1226 "path": "/tmp/test",
1227 "retries": 3,
1228 });
1229 let api = ApiError::with_details("custom_error", "something broke", details.clone());
1230
1231 assert_eq!(api.error.code, "custom_error");
1232 assert_eq!(api.error.message, "something broke");
1233 let actual_details = api.error.details.expect("details should be present");
1234 assert_eq!(actual_details, details);
1235 }
1236
1237 #[test]
1238 fn with_details_serializes_to_json_with_details_field() {
1239 let api = ApiError::with_details(
1240 "test_code",
1241 "test message",
1242 serde_json::json!({"key": "value"}),
1243 );
1244
1245 let json_str = serde_json::to_string(&api).unwrap();
1246 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1247
1248 assert_eq!(parsed["error"]["code"], "test_code");
1249 assert_eq!(parsed["error"]["message"], "test message");
1250 assert_eq!(parsed["error"]["details"]["key"], "value");
1251 }
1252
1253 #[test]
1254 fn api_error_without_details_omits_details_in_json() {
1255 let api = ApiError::new("test_code", "test message");
1256
1257 let json_str = serde_json::to_string(&api).unwrap();
1258 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1259
1260 assert_eq!(parsed["error"]["code"], "test_code");
1261 assert_eq!(parsed["error"]["message"], "test message");
1262 assert!(
1264 parsed["error"].get("details").is_none(),
1265 "details field should be omitted from JSON when None"
1266 );
1267 }
1268
1269 #[test]
1270 fn api_error_round_trips_through_json() {
1271 let original = ApiError::with_details(
1272 "not_found",
1273 "Download 42 not found",
1274 serde_json::json!({"download_id": 42}),
1275 );
1276
1277 let json_str = serde_json::to_string(&original).unwrap();
1278 let deserialized: ApiError = serde_json::from_str(&json_str).unwrap();
1279
1280 assert_eq!(deserialized.error.code, original.error.code);
1281 assert_eq!(deserialized.error.message, original.error.message);
1282 assert_eq!(deserialized.error.details, original.error.details);
1283 }
1284
1285 #[test]
1290 fn api_error_message_matches_error_display() {
1291 let err = Error::Download(DownloadError::InvalidState {
1292 id: 5,
1293 operation: "resume".into(),
1294 current_state: "completed".into(),
1295 });
1296 let display_msg = err.to_string();
1297 let api: ApiError = err.into();
1298
1299 assert_eq!(
1300 api.error.message, display_msg,
1301 "ApiError message should match the Error's Display output"
1302 );
1303 }
1304
1305 #[test]
1306 fn api_error_from_nntp_preserves_display_message_and_maps_to_502() {
1307 let err = Error::Nntp("connection reset by peer".into());
1308 let display_msg = err.to_string();
1309 let status = err.status_code();
1310 let api: ApiError = err.into();
1311
1312 assert_eq!(status, 502, "NNTP errors must map to 502 Bad Gateway");
1313 assert_eq!(api.error.code, "nntp_error");
1314 assert_eq!(
1315 api.error.message, display_msg,
1316 "ApiError message must match Error::Nntp Display output"
1317 );
1318 assert!(
1319 api.error.message.contains("connection reset by peer"),
1320 "ApiError message must contain the original NNTP error string"
1321 );
1322 assert!(
1323 api.error.details.is_none(),
1324 "NNTP errors should not have structured details"
1325 );
1326 }
1327
1328 #[test]
1329 fn api_error_message_for_insufficient_space_includes_byte_counts() {
1330 let err = Error::InsufficientSpace {
1331 required: 1_048_576,
1332 available: 512,
1333 };
1334 let api: ApiError = err.into();
1335
1336 assert!(
1337 api.error.message.contains("1048576"),
1338 "message should contain the required bytes"
1339 );
1340 assert!(
1341 api.error.message.contains("512"),
1342 "message should contain the available bytes"
1343 );
1344 }
1345}