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