1use super::{
82 ApiResponse, FilterParams, PaginatedResponse, PaginationMeta, PaginationParams, SortParams,
83 with_filters, with_pagination, with_sort,
84};
85use super::{error_reply, json_reply, with_shared};
86use hammerwork::queue::DatabaseQueue;
87use serde::{Deserialize, Serialize};
88use std::sync::Arc;
89use warp::http::StatusCode;
90use warp::{Filter, Reply};
91
92#[derive(Debug, Serialize)]
94pub struct JobInfo {
95 pub id: String,
96 pub queue_name: String,
97 pub status: String,
98 pub priority: String,
99 pub attempts: i32,
100 pub max_attempts: i32,
101 pub payload: serde_json::Value,
102 pub created_at: chrono::DateTime<chrono::Utc>,
103 pub scheduled_at: chrono::DateTime<chrono::Utc>,
104 pub started_at: Option<chrono::DateTime<chrono::Utc>>,
105 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
106 pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
107 pub error_message: Option<String>,
108 pub processing_time_ms: Option<i64>,
109 pub cron_schedule: Option<String>,
110 pub is_recurring: bool,
111 pub trace_id: Option<String>,
112 pub correlation_id: Option<String>,
113}
114
115#[derive(Debug, Deserialize, Serialize)]
117pub struct CreateJobRequest {
118 pub queue_name: String,
119 pub payload: serde_json::Value,
120 pub priority: Option<String>,
121 pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
122 pub max_attempts: Option<i32>,
123 pub cron_schedule: Option<String>,
124 pub trace_id: Option<String>,
125 pub correlation_id: Option<String>,
126}
127
128#[derive(Debug, Deserialize)]
130pub struct JobActionRequest {
131 pub action: String, pub reason: Option<String>,
133}
134
135pub const MAX_BULK_JOB_IDS: usize = 1000;
137
138#[derive(Debug, Deserialize)]
140pub struct BulkJobActionRequest {
141 pub job_ids: Vec<String>,
142 pub action: String,
143 pub reason: Option<String>,
144}
145
146#[derive(Debug, Deserialize)]
148pub struct JobSearchRequest {
149 pub query: String,
150 pub queues: Option<Vec<String>>,
151 pub statuses: Option<Vec<String>>,
152 pub priorities: Option<Vec<String>>,
153 pub created_after: Option<chrono::DateTime<chrono::Utc>>,
154 pub created_before: Option<chrono::DateTime<chrono::Utc>>,
155}
156
157pub fn routes<T>(
159 queue: Arc<T>,
160) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
161where
162 T: DatabaseQueue + Send + Sync + 'static,
163{
164 let queue_filter = with_shared(queue);
165
166 let list_jobs = warp::path("jobs")
167 .and(warp::path::end())
168 .and(warp::get())
169 .and(queue_filter.clone())
170 .and(with_pagination())
171 .and(with_filters())
172 .and(with_sort())
173 .and_then(list_jobs_handler);
174
175 let create_job = warp::path("jobs")
176 .and(warp::path::end())
177 .and(warp::post())
178 .and(queue_filter.clone())
179 .and(crate::security::json_body())
180 .and_then(create_job_handler);
181
182 let get_job = warp::path("jobs")
183 .and(warp::path::param::<String>())
184 .and(warp::path::end())
185 .and(warp::get())
186 .and(queue_filter.clone())
187 .and_then(get_job_handler);
188
189 let job_action = warp::path("jobs")
190 .and(warp::path::param::<String>())
191 .and(warp::path("actions"))
192 .and(warp::path::end())
193 .and(warp::post())
194 .and(queue_filter.clone())
195 .and(crate::security::json_body())
196 .and_then(job_action_handler);
197
198 let bulk_action = warp::path("jobs")
199 .and(warp::path("bulk"))
200 .and(warp::path::end())
201 .and(warp::post())
202 .and(queue_filter.clone())
203 .and(crate::security::json_body())
204 .and_then(bulk_job_action_handler);
205
206 let search_jobs = warp::path("jobs")
207 .and(warp::path("search"))
208 .and(warp::path::end())
209 .and(warp::post())
210 .and(queue_filter)
211 .and(crate::security::json_body())
212 .and(with_pagination())
213 .and_then(search_jobs_handler);
214
215 list_jobs
216 .or(create_job)
217 .or(get_job)
218 .or(job_action)
219 .or(bulk_action)
220 .or(search_jobs)
221}
222
223fn job_info(job: &hammerwork::Job) -> JobInfo {
225 let end = job.completed_at.or(job.failed_at).or(job.timed_out_at);
226 JobInfo {
227 id: job.id.to_string(),
228 queue_name: job.queue_name.clone(),
229 status: job.status.as_str().to_string(),
230 priority: job.priority.to_string(),
231 attempts: job.attempts,
232 max_attempts: job.max_attempts,
233 payload: job.payload.clone(),
234 created_at: job.created_at,
235 scheduled_at: job.scheduled_at,
236 started_at: job.started_at,
237 completed_at: job.completed_at,
238 failed_at: job.failed_at,
239 error_message: job.error_message.clone(),
240 processing_time_ms: job
241 .started_at
242 .zip(end)
243 .map(|(start, end)| (end - start).num_milliseconds()),
244 cron_schedule: job.cron_schedule.clone(),
245 is_recurring: job.is_recurring(),
246 trace_id: job.trace_id.clone(),
247 correlation_id: job.correlation_id.clone(),
248 }
249}
250
251fn status_matches(filter: &str, job: &JobInfo) -> bool {
255 let status = job.status.to_lowercase();
256 match filter {
257 "failed" => matches!(status.as_str(), "failed" | "dead" | "timedout"),
258 "recurring" => job.is_recurring,
259 other => status == other,
260 }
261}
262
263fn priority_rank(priority: &str) -> i32 {
265 priority
266 .parse::<hammerwork::JobPriority>()
267 .map(|p| p.as_i32())
268 .unwrap_or(-1)
269}
270
271async fn collect_jobs<T>(
274 queue: &T,
275 queue_name: &str,
276 include_ready: bool,
277 include_dead: bool,
278 include_recurring: bool,
279 limit: u32,
280) -> hammerwork::Result<Vec<hammerwork::Job>>
281where
282 T: DatabaseQueue + Send + Sync,
283{
284 let mut jobs = Vec::new();
285 if include_ready {
286 jobs.extend(queue.get_ready_jobs(queue_name, limit).await?);
287 }
288 if include_dead {
289 jobs.extend(
290 queue
291 .get_dead_jobs_by_queue(queue_name, Some(limit), Some(0))
292 .await?,
293 );
294 }
295 if include_recurring {
296 jobs.extend(queue.get_recurring_jobs(queue_name).await?);
297 }
298 let mut seen = std::collections::HashSet::new();
299 jobs.retain(|job| seen.insert(job.id));
300 Ok(jobs)
301}
302
303fn paginate<I>(items: Vec<I>, pagination: &PaginationParams) -> PaginatedResponse<I> {
305 let limit = pagination.limit.unwrap_or(20).clamp(1, 100);
306 let page = pagination.page.unwrap_or(1).max(1);
307 let offset = pagination
308 .offset
309 .unwrap_or_else(|| (page - 1).saturating_mul(limit));
310 let total = items.len() as u64;
311 let items = items
312 .into_iter()
313 .skip(offset as usize)
314 .take(limit as usize)
315 .collect();
316 let served = PaginationParams {
318 page: Some((offset / limit).saturating_add(1)),
319 limit: Some(limit),
320 offset: Some(offset),
321 };
322 PaginatedResponse {
323 items,
324 pagination: PaginationMeta::new(&served, total),
325 }
326}
327
328pub(crate) async fn list_jobs_handler<T>(
330 queue: Arc<T>,
331 pagination: PaginationParams,
332 filters: FilterParams,
333 sort: SortParams,
334) -> Result<impl Reply, warp::Rejection>
335where
336 T: DatabaseQueue + Send + Sync,
337{
338 let mut all_jobs = Vec::new();
341
342 let queue_stats = match queue.get_all_queue_stats().await {
344 Ok(stats) => stats,
345 Err(e) => {
346 return Ok(error_reply(
347 StatusCode::INTERNAL_SERVER_ERROR,
348 format!("Failed to get queue stats: {}", e),
349 ));
350 }
351 };
352
353 let target_queues: Vec<String> = if let Some(ref queue_name) = filters.queue {
355 vec![queue_name.clone()]
356 } else {
357 queue_stats.iter().map(|s| s.queue_name.clone()).collect()
358 };
359
360 let status_filter = filters.status.as_deref().map(str::to_lowercase);
361 let search_term = filters
362 .search
363 .as_deref()
364 .map(str::trim)
365 .filter(|term| !term.is_empty())
366 .map(str::to_lowercase);
367 let wants = |names: &[&str]| {
368 status_filter
369 .as_deref()
370 .is_none_or(|status| names.contains(&status))
371 };
372
373 for queue_name in &target_queues {
375 let queue_jobs = try_api!(
376 collect_jobs(
377 queue.as_ref(),
378 queue_name,
379 wants(&["pending"]),
380 wants(&["failed", "dead"]),
381 wants(&["recurring"]),
382 100
383 )
384 .await,
385 "Failed to list jobs"
386 );
387
388 for job in &queue_jobs {
389 if filters
390 .created_after
391 .is_some_and(|after| job.created_at < after)
392 || filters
393 .created_before
394 .is_some_and(|before| job.created_at > before)
395 {
396 continue;
397 }
398 if let Some(ref term) = search_term
399 && !job_matches_search(job, term)
400 {
401 continue;
402 }
403
404 let job_info = job_info(job);
405
406 if let Some(ref status) = status_filter
408 && !status_matches(status, &job_info)
409 {
410 continue;
411 }
412
413 if let Some(ref priority) = filters.priority
415 && !job_info.priority.eq_ignore_ascii_case(priority)
416 {
417 continue;
418 }
419
420 all_jobs.push(job_info);
421 }
422 }
423
424 match sort.sort_by.as_deref() {
428 Some("scheduled_at") => all_jobs.sort_by_key(|j| j.scheduled_at),
429 Some("priority") => all_jobs.sort_by_key(|j| priority_rank(&j.priority)),
430 _ => all_jobs.sort_by_key(|j| j.created_at),
431 }
432 if !sort.is_ascending() {
433 all_jobs.reverse();
434 }
435
436 Ok(json_reply(&ApiResponse::success(paginate(
437 all_jobs,
438 &pagination,
439 ))))
440}
441
442async fn create_job_handler<T>(
444 queue: Arc<T>,
445 request: CreateJobRequest,
446) -> Result<impl Reply, warp::Rejection>
447where
448 T: DatabaseQueue + Send + Sync,
449{
450 use hammerwork::{CronSchedule, Job, JobPriority};
451
452 if request.queue_name.trim().is_empty() {
453 return Ok(error_reply(
454 StatusCode::BAD_REQUEST,
455 "queue_name must not be empty",
456 ));
457 }
458
459 let priority = match request.priority.as_deref() {
460 None => JobPriority::Normal,
461 Some(name) => match name.parse::<JobPriority>() {
462 Ok(priority) => priority,
463 Err(_) => {
464 return Ok(error_reply(
465 StatusCode::BAD_REQUEST,
466 format!(
467 "Invalid priority '{}'. Valid options: background, low, normal, high, critical",
468 name
469 ),
470 ));
471 }
472 },
473 };
474
475 if let Some(max_attempts) = request.max_attempts
476 && max_attempts < 1
477 {
478 return Ok(error_reply(
479 StatusCode::BAD_REQUEST,
480 "max_attempts must be at least 1",
481 ));
482 }
483
484 let mut job = Job::new(request.queue_name, request.payload).with_priority(priority);
485
486 if let Some(expression) = request.cron_schedule.as_deref() {
487 let schedule = match CronSchedule::new(expression) {
488 Ok(schedule) => schedule,
489 Err(e) => {
490 return Ok(error_reply(
491 StatusCode::BAD_REQUEST,
492 format!("Invalid cron schedule: {}", e),
493 ));
494 }
495 };
496 job = match job.with_cron(schedule) {
497 Ok(job) => job,
498 Err(e) => {
499 return Ok(error_reply(
500 StatusCode::BAD_REQUEST,
501 format!("Invalid cron schedule: {}", e),
502 ));
503 }
504 };
505 } else if let Some(scheduled_at) = request.scheduled_at {
506 job.scheduled_at = scheduled_at;
507 }
508
509 if let Some(max_attempts) = request.max_attempts {
510 job = job.with_max_attempts(max_attempts);
511 }
512
513 if let Some(trace_id) = request.trace_id {
514 job.trace_id = Some(trace_id);
515 }
516
517 if let Some(correlation_id) = request.correlation_id {
518 job.correlation_id = Some(correlation_id);
519 }
520
521 match queue.enqueue(job).await {
522 Ok(job_id) => {
523 let response = ApiResponse::success(serde_json::json!({
524 "message": "Job created successfully",
525 "job_id": job_id.to_string()
526 }));
527 Ok(json_reply(&response))
528 }
529 Err(e) => Ok(error_reply(
530 StatusCode::INTERNAL_SERVER_ERROR,
531 format!("Failed to create job: {}", e),
532 )),
533 }
534}
535
536async fn get_job_handler<T>(job_id: String, queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
538where
539 T: DatabaseQueue + Send + Sync,
540{
541 let job_uuid = match uuid::Uuid::parse_str(&job_id) {
542 Ok(uuid) => uuid,
543 Err(_) => {
544 return Ok(error_reply(
545 StatusCode::BAD_REQUEST,
546 "Invalid job ID format".to_string(),
547 ));
548 }
549 };
550
551 match queue.get_job(job_uuid).await {
552 Ok(Some(job)) => Ok(json_reply(&ApiResponse::success(job_info(&job)))),
553 Ok(None) => Ok(error_reply(
554 StatusCode::NOT_FOUND,
555 format!("Job '{}' not found", job_id),
556 )),
557 Err(e) => Ok(error_reply(
558 StatusCode::INTERNAL_SERVER_ERROR,
559 format!("Failed to get job: {}", e),
560 )),
561 }
562}
563
564async fn job_action_handler<T>(
566 job_id: String,
567 queue: Arc<T>,
568 action_request: JobActionRequest,
569) -> Result<impl Reply, warp::Rejection>
570where
571 T: DatabaseQueue + Send + Sync,
572{
573 let job_uuid = match uuid::Uuid::parse_str(&job_id) {
574 Ok(uuid) => uuid,
575 Err(_) => {
576 return Ok(error_reply(
577 StatusCode::BAD_REQUEST,
578 "Invalid job ID format".to_string(),
579 ));
580 }
581 };
582
583 match action_request.action.as_str() {
584 "retry" => match retry_job_action(queue.as_ref(), job_uuid).await {
585 Ok(()) => {
586 let response = ApiResponse::success(serde_json::json!({
587 "message": format!("Job '{}' scheduled for retry", job_id)
588 }));
589 Ok(json_reply(&response))
590 }
591 Err(e) => Ok(super::queue_error_reply("Failed to retry job", &e)),
592 },
593 "cancel" | "delete" => match delete_job_action(queue.as_ref(), job_uuid).await {
594 Ok(()) => {
595 let response = ApiResponse::success(serde_json::json!({
596 "message": format!("Job '{}' deleted", job_id)
597 }));
598 Ok(json_reply(&response))
599 }
600 Err(e) => Ok(super::queue_error_reply("Failed to delete job", &e)),
601 },
602 _ => Ok(error_reply(
603 StatusCode::BAD_REQUEST,
604 format!("Unknown action: {}", action_request.action),
605 )),
606 }
607}
608
609async fn delete_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
611where
612 T: DatabaseQueue + Send + Sync,
613{
614 if queue.get_job(job_id).await?.is_none() {
615 return Err(hammerwork::HammerworkError::JobNotFound {
616 id: job_id.to_string(),
617 });
618 }
619 queue.delete_job(job_id).await
620}
621
622async fn retry_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
627where
628 T: DatabaseQueue + Send + Sync,
629{
630 let job =
631 queue
632 .get_job(job_id)
633 .await?
634 .ok_or_else(|| hammerwork::HammerworkError::JobNotFound {
635 id: job_id.to_string(),
636 })?;
637 match job.status {
638 hammerwork::JobStatus::Dead | hammerwork::JobStatus::TimedOut => {
639 queue.retry_dead_job(job_id).await
640 }
641 _ => queue.retry_job(job_id, chrono::Utc::now()).await,
642 }
643}
644
645async fn bulk_job_action_handler<T>(
647 queue: Arc<T>,
648 request: BulkJobActionRequest,
649) -> Result<impl Reply, warp::Rejection>
650where
651 T: DatabaseQueue + Send + Sync,
652{
653 if !matches!(request.action.as_str(), "retry" | "delete") {
654 return Ok(error_reply(
655 StatusCode::BAD_REQUEST,
656 format!("Unknown action: {}", request.action),
657 ));
658 }
659 if request.job_ids.len() > MAX_BULK_JOB_IDS {
660 return Ok(error_reply(
661 StatusCode::BAD_REQUEST,
662 format!(
663 "Too many job IDs: {} (at most {} per request)",
664 request.job_ids.len(),
665 MAX_BULK_JOB_IDS
666 ),
667 ));
668 }
669
670 let mut successful = 0;
671 let mut failed = 0;
672 let mut errors = Vec::new();
673
674 for job_id_str in &request.job_ids {
675 let job_uuid = match uuid::Uuid::parse_str(job_id_str) {
676 Ok(uuid) => uuid,
677 Err(_) => {
678 failed += 1;
679 errors.push(format!("Invalid job ID: {}", job_id_str));
680 continue;
681 }
682 };
683
684 let result = match request.action.as_str() {
685 "retry" => retry_job_action(queue.as_ref(), job_uuid).await,
686 _ => delete_job_action(queue.as_ref(), job_uuid).await,
687 };
688
689 match result {
690 Ok(()) => successful += 1,
691 Err(e) => {
692 failed += 1;
693 errors.push(format!("Job {}: {}", job_id_str, e));
694 }
695 }
696 }
697
698 let response = ApiResponse::success(serde_json::json!({
699 "successful": successful,
700 "failed": failed,
701 "errors": errors,
702 "message": format!("Bulk {} completed: {} successful, {} failed", request.action, successful, failed)
703 }));
704
705 Ok(json_reply(&response))
706}
707
708fn job_matches_search(job: &hammerwork::Job, term: &str) -> bool {
710 job.id.to_string().contains(term)
711 || job.queue_name.to_lowercase().contains(term)
712 || payload_search_text(&job.payload).contains(term)
713 || [&job.error_message, &job.trace_id, &job.correlation_id]
714 .into_iter()
715 .flatten()
716 .any(|text| text.to_lowercase().contains(term))
717}
718
719async fn search_jobs_handler<T>(
721 queue: Arc<T>,
722 search_request: JobSearchRequest,
723 pagination: PaginationParams,
724) -> Result<impl Reply, warp::Rejection>
725where
726 T: DatabaseQueue + Send + Sync,
727{
728 let mut matching_jobs = Vec::new();
730 let search_term = search_request.query.to_lowercase();
731
732 let queue_stats = match queue.get_all_queue_stats().await {
734 Ok(stats) => stats,
735 Err(e) => {
736 return Ok(error_reply(
737 StatusCode::INTERNAL_SERVER_ERROR,
738 format!("Failed to get queue stats: {}", e),
739 ));
740 }
741 };
742
743 let target_queues: Vec<String> = if let Some(ref queue_names) = search_request.queues {
745 queue_names.clone()
746 } else {
747 queue_stats.iter().map(|s| s.queue_name.clone()).collect()
748 };
749
750 for queue_name in &target_queues {
751 let queue_jobs = try_api!(
753 collect_jobs(queue.as_ref(), queue_name, true, true, true, 200).await,
754 "Failed to list jobs"
755 );
756
757 for job in &queue_jobs {
758 if !job_matches_search(job, &search_term) {
759 continue;
760 }
761
762 if let Some(ref statuses) = search_request.statuses
764 && !statuses
765 .iter()
766 .any(|s| s.eq_ignore_ascii_case(job.status.as_str()))
767 {
768 continue;
769 }
770
771 if let Some(ref priorities) = search_request.priorities {
773 let job_priority = job.priority.to_string();
774 if !priorities
775 .iter()
776 .any(|p| p.eq_ignore_ascii_case(&job_priority))
777 {
778 continue;
779 }
780 }
781
782 if let Some(ref created_after) = search_request.created_after
784 && job.created_at < *created_after
785 {
786 continue;
787 }
788
789 if let Some(ref created_before) = search_request.created_before
790 && job.created_at > *created_before
791 {
792 continue;
793 }
794
795 matching_jobs.push(job_info(job));
796 }
797 }
798
799 matching_jobs.sort_by_key(|j| std::cmp::Reverse(j.created_at));
801
802 Ok(json_reply(&ApiResponse::success(paginate(
803 matching_jobs,
804 &pagination,
805 ))))
806}
807
808fn payload_search_text(payload: &serde_json::Value) -> String {
814 match serde_json::to_string(payload) {
815 Ok(text) => text.to_lowercase(),
816 Err(e) => {
817 tracing::warn!(error = %e, "failed to serialize job payload for search");
818 payload.to_string().to_lowercase()
819 }
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::api::test_support::{body_json, unreachable_queue};
827
828 #[tokio::test]
829 async fn test_list_jobs_returns_500_when_database_is_down() {
830 let response = list_jobs_handler(
831 unreachable_queue(),
832 PaginationParams::default(),
833 serde_json::from_value(serde_json::json!({})).unwrap(),
834 SortParams {
835 sort_by: None,
836 sort_order: None,
837 },
838 )
839 .await
840 .unwrap()
841 .into_response();
842 let (status, body) = body_json(response).await;
843 assert_eq!(status, 500);
844 assert_eq!(body["success"], false);
845 assert!(
846 body["error"]
847 .as_str()
848 .unwrap()
849 .contains("Failed to get queue stats")
850 );
851 }
852
853 async fn listed_ids(
856 queue: Arc<hammerwork::queue::test::TestQueue>,
857 query: serde_json::Value,
858 ) -> Vec<String> {
859 let response = list_jobs_handler(
860 queue,
861 PaginationParams::default(),
862 serde_json::from_value(query.clone()).unwrap(),
863 serde_json::from_value(query).unwrap(),
864 )
865 .await
866 .unwrap()
867 .into_response();
868 let (status, body) = body_json(response).await;
869 assert_eq!(status, 200, "{body}");
870 body["data"]["items"]
871 .as_array()
872 .unwrap()
873 .iter()
874 .map(|item| item["id"].as_str().unwrap().to_string())
875 .collect()
876 }
877
878 async fn two_jobs() -> (
880 Arc<hammerwork::queue::test::TestQueue>,
881 String,
882 String,
883 chrono::DateTime<chrono::Utc>,
884 ) {
885 use hammerwork::{Job, queue::test::TestQueue};
886 let queue = Arc::new(TestQueue::new());
887 let first = queue
888 .enqueue(Job::new("q".into(), serde_json::json!({"name": "alpha"})))
889 .await
890 .unwrap();
891 let between = queue.clock().now() + chrono::Duration::seconds(30);
892 queue.clock().advance(chrono::Duration::minutes(1));
893 let second = queue
894 .enqueue(Job::new("q".into(), serde_json::json!({"name": "beta"})))
895 .await
896 .unwrap();
897 (queue, first.to_string(), second.to_string(), between)
898 }
899
900 #[tokio::test]
901 async fn test_list_jobs_applies_created_and_search_filters() {
902 let (queue, first, second, between) = two_jobs().await;
903 let ids = |query| listed_ids(queue.clone(), query);
904
905 assert_eq!(
906 ids(serde_json::json!({"queue": "q", "created_after": between})).await,
907 vec![second.clone()]
908 );
909 assert_eq!(
910 ids(serde_json::json!({"queue": "q", "created_before": between})).await,
911 vec![first.clone()]
912 );
913 assert_eq!(
914 ids(serde_json::json!({"queue": "q", "search": "ALPHA"})).await,
915 vec![first]
916 );
917 assert!(
918 ids(serde_json::json!({"queue": "q", "search": "no such job"}))
919 .await
920 .is_empty()
921 );
922 }
923
924 #[tokio::test]
925 async fn test_list_jobs_sort_order_applies_to_the_default_sort() {
926 let (queue, first, second, _) = two_jobs().await;
927 let ids = |query| listed_ids(queue.clone(), query);
928
929 assert_eq!(
931 ids(serde_json::json!({"queue": "q"})).await,
932 vec![second.clone(), first.clone()]
933 );
934 assert_eq!(
936 ids(serde_json::json!({"queue": "q", "sort_order": "asc"})).await,
937 vec![first.clone(), second.clone()]
938 );
939 assert_eq!(
941 ids(serde_json::json!({"queue": "q", "sort_by": "attempts", "sort_order": "ASC"}))
942 .await,
943 vec![first.clone(), second.clone()]
944 );
945 assert_eq!(
946 ids(serde_json::json!({"queue": "q", "sort_by": "created_at", "sort_order": "desc"}))
947 .await,
948 vec![second, first]
949 );
950 }
951
952 #[tokio::test]
953 async fn test_invalid_job_id_is_400() {
954 let response = get_job_handler("not-a-uuid".to_string(), unreachable_queue())
955 .await
956 .unwrap()
957 .into_response();
958 let (status, _) = body_json(response).await;
959 assert_eq!(status, 400);
960 }
961
962 #[test]
963 fn test_payload_search_text_lowercases_json() {
964 let payload = serde_json::json!({"To": "User@Example.com"});
965 let text = payload_search_text(&payload);
966 assert!(text.contains("user@example.com"));
967 assert!(text.contains("\"to\""));
968 }
969
970 #[test]
971 fn test_create_job_request_deserialization() {
972 let json = r#"{
973 "queue_name": "email",
974 "payload": {"to": "user@example.com", "subject": "Hello"},
975 "priority": "high",
976 "max_attempts": 5
977 }"#;
978
979 let request: CreateJobRequest = serde_json::from_str(json).unwrap();
980 assert_eq!(request.queue_name, "email");
981 assert_eq!(request.priority, Some("high".to_string()));
982 assert_eq!(request.max_attempts, Some(5));
983 }
984
985 #[test]
986 fn test_job_action_request_deserialization() {
987 let json = r#"{"action": "retry", "reason": "Network error resolved"}"#;
988 let request: JobActionRequest = serde_json::from_str(json).unwrap();
989 assert_eq!(request.action, "retry");
990 assert_eq!(request.reason, Some("Network error resolved".to_string()));
991 }
992
993 #[test]
994 fn test_bulk_job_action_request() {
995 let json = r#"{
996 "job_ids": ["job-1", "job-2", "job-3"],
997 "action": "delete",
998 "reason": "Cleanup old jobs"
999 }"#;
1000
1001 let request: BulkJobActionRequest = serde_json::from_str(json).unwrap();
1002 assert_eq!(request.job_ids.len(), 3);
1003 assert_eq!(request.action, "delete");
1004 }
1005}