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
255const SEARCH_LIMIT: u32 = 200;
257
258const ALL_STATUSES: [&str; 7] = [
260 "Pending",
261 "Running",
262 "Completed",
263 "Failed",
264 "Dead",
265 "TimedOut",
266 "Retrying",
267];
268
269fn stored_statuses(filter: &str) -> Option<&'static [&'static str]> {
273 Some(match filter {
274 "pending" => &["Pending"],
275 "running" => &["Running"],
276 "completed" => &["Completed"],
277 "failed" => &["Failed", "Dead", "TimedOut"],
278 "dead" => &["Dead"],
279 "timedout" => &["TimedOut"],
280 "retrying" => &["Retrying"],
281 _ => return None,
282 })
283}
284
285fn status_matches(filter: &str, job: &JobInfo) -> bool {
289 let status = job.status.to_lowercase();
290 match filter {
291 "failed" => matches!(status.as_str(), "failed" | "dead" | "timedout"),
292 "recurring" => job.is_recurring,
293 other => status == other,
294 }
295}
296
297fn priority_rank(priority: &str) -> i32 {
299 priority
300 .parse::<hammerwork::JobPriority>()
301 .map(|p| p.as_i32())
302 .unwrap_or(-1)
303}
304
305async fn collect_jobs<T>(
308 queue: &T,
309 queue_name: &str,
310 include_ready: bool,
311 include_dead: bool,
312 include_recurring: bool,
313 limit: u32,
314) -> hammerwork::Result<Vec<hammerwork::Job>>
315where
316 T: DatabaseQueue + Send + Sync,
317{
318 let mut jobs = Vec::new();
319 if include_ready {
320 jobs.extend(queue.get_ready_jobs(queue_name, limit).await?);
321 }
322 if include_dead {
323 jobs.extend(
324 queue
325 .get_dead_jobs_by_queue(queue_name, Some(limit), Some(0))
326 .await?,
327 );
328 }
329 if include_recurring {
330 jobs.extend(queue.get_recurring_jobs(queue_name).await?);
331 }
332 let mut seen = std::collections::HashSet::new();
333 jobs.retain(|job| seen.insert(job.id));
334 Ok(jobs)
335}
336
337fn paginate<I>(items: Vec<I>, pagination: &PaginationParams) -> PaginatedResponse<I> {
339 let limit = pagination.limit.unwrap_or(20).clamp(1, 100);
340 let page = pagination.page.unwrap_or(1).max(1);
341 let offset = pagination
342 .offset
343 .unwrap_or_else(|| (page - 1).saturating_mul(limit));
344 let total = items.len() as u64;
345 let items = items
346 .into_iter()
347 .skip(offset as usize)
348 .take(limit as usize)
349 .collect();
350 let served = PaginationParams {
352 page: Some((offset / limit).saturating_add(1)),
353 limit: Some(limit),
354 offset: Some(offset),
355 };
356 PaginatedResponse {
357 items,
358 pagination: PaginationMeta::new(&served, total),
359 }
360}
361
362pub(crate) async fn list_jobs_handler<T>(
364 queue: Arc<T>,
365 pagination: PaginationParams,
366 filters: FilterParams,
367 sort: SortParams,
368) -> Result<impl Reply, warp::Rejection>
369where
370 T: JobHistory,
371{
372 let status_filter = filters
373 .status
374 .as_deref()
375 .map(str::to_lowercase)
376 .map(|status| {
377 if status == "timed_out" {
378 "timedout".to_string()
379 } else {
380 status
381 }
382 });
383 let search_term = filters
384 .search
385 .as_deref()
386 .map(str::trim)
387 .filter(|term| !term.is_empty())
388 .map(str::to_lowercase);
389
390 let mut all_jobs = Vec::new();
391 let queue_jobs = if let Some(statuses) = status_filter.as_deref().and_then(stored_statuses) {
392 let mut jobs = Vec::new();
394 for status in statuses {
395 jobs.extend(try_api!(
396 queue
397 .jobs_with_status(filters.queue.as_deref(), status, LISTING_LIMIT)
398 .await,
399 "Failed to list jobs"
400 ));
401 }
402 jobs
403 } else {
404 let queue_stats = match queue.get_all_queue_stats().await {
407 Ok(stats) => stats,
408 Err(e) => {
409 return Ok(error_reply(
410 StatusCode::INTERNAL_SERVER_ERROR,
411 format!("Failed to get queue stats: {}", e),
412 ));
413 }
414 };
415 let target_queues: Vec<String> = if let Some(ref queue_name) = filters.queue {
416 vec![queue_name.clone()]
417 } else {
418 queue_stats.iter().map(|s| s.queue_name.clone()).collect()
419 };
420 let wants = |names: &[&str]| {
421 status_filter
422 .as_deref()
423 .is_none_or(|status| names.contains(&status))
424 };
425 let mut jobs = Vec::new();
426 for queue_name in &target_queues {
427 jobs.extend(try_api!(
428 collect_jobs(
429 queue.as_ref(),
430 queue_name,
431 wants(&["pending"]),
432 wants(&["failed", "dead"]),
433 wants(&["recurring"]),
434 LISTING_LIMIT
435 )
436 .await,
437 "Failed to list jobs"
438 ));
439 }
440 jobs
441 };
442
443 for job in &queue_jobs {
444 if filters
445 .created_after
446 .is_some_and(|after| job.created_at < after)
447 || filters
448 .created_before
449 .is_some_and(|before| job.created_at > before)
450 {
451 continue;
452 }
453 if let Some(ref term) = search_term
454 && !job_matches_search(job, term)
455 {
456 continue;
457 }
458
459 let job_info = job_info(job);
460
461 if let Some(ref status) = status_filter
463 && !status_matches(status, &job_info)
464 {
465 continue;
466 }
467
468 if let Some(ref priority) = filters.priority
470 && !job_info.priority.eq_ignore_ascii_case(priority)
471 {
472 continue;
473 }
474
475 all_jobs.push(job_info);
476 }
477
478 match sort.sort_by.as_deref() {
482 Some("scheduled_at") => all_jobs.sort_by_key(|j| j.scheduled_at),
483 Some("priority") => all_jobs.sort_by_key(|j| priority_rank(&j.priority)),
484 _ => all_jobs.sort_by_key(|j| j.created_at),
485 }
486 if !sort.is_ascending() {
487 all_jobs.reverse();
488 }
489
490 Ok(json_reply(&ApiResponse::success(paginate(
491 all_jobs,
492 &pagination,
493 ))))
494}
495
496async fn create_job_handler<T>(
498 queue: Arc<T>,
499 request: CreateJobRequest,
500) -> Result<impl Reply, warp::Rejection>
501where
502 T: DatabaseQueue + Send + Sync,
503{
504 use hammerwork::{CronSchedule, Job, JobPriority};
505
506 if request.queue_name.trim().is_empty() {
507 return Ok(error_reply(
508 StatusCode::BAD_REQUEST,
509 "queue_name must not be empty",
510 ));
511 }
512
513 let priority = match request.priority.as_deref() {
514 None => JobPriority::Normal,
515 Some(name) => match name.parse::<JobPriority>() {
516 Ok(priority) => priority,
517 Err(_) => {
518 return Ok(error_reply(
519 StatusCode::BAD_REQUEST,
520 format!(
521 "Invalid priority '{}'. Valid options: background, low, normal, high, critical",
522 name
523 ),
524 ));
525 }
526 },
527 };
528
529 if let Some(max_attempts) = request.max_attempts
530 && max_attempts < 1
531 {
532 return Ok(error_reply(
533 StatusCode::BAD_REQUEST,
534 "max_attempts must be at least 1",
535 ));
536 }
537
538 let mut job = Job::new(request.queue_name, request.payload).with_priority(priority);
539
540 if let Some(expression) = request.cron_schedule.as_deref() {
541 let schedule = match CronSchedule::new(expression) {
542 Ok(schedule) => schedule,
543 Err(e) => {
544 return Ok(error_reply(
545 StatusCode::BAD_REQUEST,
546 format!("Invalid cron schedule: {}", e),
547 ));
548 }
549 };
550 job = match job.with_cron(schedule) {
551 Ok(job) => job,
552 Err(e) => {
553 return Ok(error_reply(
554 StatusCode::BAD_REQUEST,
555 format!("Invalid cron schedule: {}", e),
556 ));
557 }
558 };
559 } else if let Some(scheduled_at) = request.scheduled_at {
560 job.scheduled_at = scheduled_at;
561 }
562
563 if let Some(max_attempts) = request.max_attempts {
564 job = job.with_max_attempts(max_attempts);
565 }
566
567 if let Some(trace_id) = request.trace_id {
568 job.trace_id = Some(trace_id);
569 }
570
571 if let Some(correlation_id) = request.correlation_id {
572 job.correlation_id = Some(correlation_id);
573 }
574
575 match queue.enqueue(job).await {
576 Ok(job_id) => {
577 let response = ApiResponse::success(serde_json::json!({
578 "message": "Job created successfully",
579 "job_id": job_id.to_string()
580 }));
581 Ok(json_reply(&response))
582 }
583 Err(e) => Ok(error_reply(
584 StatusCode::INTERNAL_SERVER_ERROR,
585 format!("Failed to create job: {}", e),
586 )),
587 }
588}
589
590async fn get_job_handler<T>(job_id: String, queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
592where
593 T: DatabaseQueue + Send + Sync,
594{
595 let job_uuid = match uuid::Uuid::parse_str(&job_id) {
596 Ok(uuid) => uuid,
597 Err(_) => {
598 return Ok(error_reply(
599 StatusCode::BAD_REQUEST,
600 "Invalid job ID format".to_string(),
601 ));
602 }
603 };
604
605 match queue.get_job(job_uuid).await {
606 Ok(Some(job)) => Ok(json_reply(&ApiResponse::success(job_info(&job)))),
607 Ok(None) => Ok(error_reply(
608 StatusCode::NOT_FOUND,
609 format!("Job '{}' not found", job_id),
610 )),
611 Err(e) => Ok(error_reply(
612 StatusCode::INTERNAL_SERVER_ERROR,
613 format!("Failed to get job: {}", e),
614 )),
615 }
616}
617
618async fn job_action_handler<T>(
620 job_id: String,
621 queue: Arc<T>,
622 action_request: JobActionRequest,
623) -> Result<impl Reply, warp::Rejection>
624where
625 T: DatabaseQueue + Send + Sync,
626{
627 let job_uuid = match uuid::Uuid::parse_str(&job_id) {
628 Ok(uuid) => uuid,
629 Err(_) => {
630 return Ok(error_reply(
631 StatusCode::BAD_REQUEST,
632 "Invalid job ID format".to_string(),
633 ));
634 }
635 };
636
637 match action_request.action.as_str() {
638 "retry" => match retry_job_action(queue.as_ref(), job_uuid).await {
639 Ok(()) => {
640 let response = ApiResponse::success(serde_json::json!({
641 "message": format!("Job '{}' scheduled for retry", job_id)
642 }));
643 Ok(json_reply(&response))
644 }
645 Err(e) => Ok(super::queue_error_reply("Failed to retry job", &e)),
646 },
647 "cancel" | "delete" => match delete_job_action(queue.as_ref(), job_uuid).await {
648 Ok(()) => {
649 let response = ApiResponse::success(serde_json::json!({
650 "message": format!("Job '{}' deleted", job_id)
651 }));
652 Ok(json_reply(&response))
653 }
654 Err(e) => Ok(super::queue_error_reply("Failed to delete job", &e)),
655 },
656 _ => Ok(error_reply(
657 StatusCode::BAD_REQUEST,
658 format!("Unknown action: {}", action_request.action),
659 )),
660 }
661}
662
663async fn delete_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
665where
666 T: DatabaseQueue + Send + Sync,
667{
668 if queue.get_job(job_id).await?.is_none() {
669 return Err(hammerwork::HammerworkError::JobNotFound {
670 id: job_id.to_string(),
671 });
672 }
673 queue.delete_job(job_id).await
674}
675
676async fn retry_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
681where
682 T: DatabaseQueue + Send + Sync,
683{
684 let job =
685 queue
686 .get_job(job_id)
687 .await?
688 .ok_or_else(|| hammerwork::HammerworkError::JobNotFound {
689 id: job_id.to_string(),
690 })?;
691 match job.status {
692 hammerwork::JobStatus::Dead | hammerwork::JobStatus::TimedOut => {
693 queue.retry_dead_job(job_id).await
694 }
695 _ => queue.retry_job(job_id, chrono::Utc::now()).await,
696 }
697}
698
699async fn bulk_job_action_handler<T>(
701 queue: Arc<T>,
702 request: BulkJobActionRequest,
703) -> Result<impl Reply, warp::Rejection>
704where
705 T: DatabaseQueue + Send + Sync,
706{
707 if !matches!(request.action.as_str(), "retry" | "delete") {
708 return Ok(error_reply(
709 StatusCode::BAD_REQUEST,
710 format!("Unknown action: {}", request.action),
711 ));
712 }
713 if request.job_ids.len() > MAX_BULK_JOB_IDS {
714 return Ok(error_reply(
715 StatusCode::BAD_REQUEST,
716 format!(
717 "Too many job IDs: {} (at most {} per request)",
718 request.job_ids.len(),
719 MAX_BULK_JOB_IDS
720 ),
721 ));
722 }
723
724 let mut successful = 0;
725 let mut failed = 0;
726 let mut errors = Vec::new();
727
728 for job_id_str in &request.job_ids {
729 let job_uuid = match uuid::Uuid::parse_str(job_id_str) {
730 Ok(uuid) => uuid,
731 Err(_) => {
732 failed += 1;
733 errors.push(format!("Invalid job ID: {}", job_id_str));
734 continue;
735 }
736 };
737
738 let result = match request.action.as_str() {
739 "retry" => retry_job_action(queue.as_ref(), job_uuid).await,
740 _ => delete_job_action(queue.as_ref(), job_uuid).await,
741 };
742
743 match result {
744 Ok(()) => successful += 1,
745 Err(e) => {
746 failed += 1;
747 errors.push(format!("Job {}: {}", job_id_str, e));
748 }
749 }
750 }
751
752 let response = ApiResponse::success(serde_json::json!({
753 "successful": successful,
754 "failed": failed,
755 "errors": errors,
756 "message": format!("Bulk {} completed: {} successful, {} failed", request.action, successful, failed)
757 }));
758
759 Ok(json_reply(&response))
760}
761
762fn job_matches_search(job: &hammerwork::Job, term: &str) -> bool {
764 job.id.to_string().contains(term)
765 || job.queue_name.to_lowercase().contains(term)
766 || payload_search_text(&job.payload).contains(term)
767 || [&job.error_message, &job.trace_id, &job.correlation_id]
768 .into_iter()
769 .flatten()
770 .any(|text| text.to_lowercase().contains(term))
771}
772
773async fn search_jobs_handler<T>(
775 queue: Arc<T>,
776 search_request: JobSearchRequest,
777 pagination: PaginationParams,
778) -> Result<impl Reply, warp::Rejection>
779where
780 T: JobHistory,
781{
782 let search_term = search_request.query.to_lowercase();
783
784 let statuses: Vec<&'static str> = match search_request.statuses {
786 Some(ref requested) if !requested.is_empty() => {
787 let mut stored: Vec<&'static str> = Vec::new();
788 for name in requested {
789 let name = name.to_lowercase().replace('_', "");
790 for status in stored_statuses(&name).unwrap_or(&[]) {
791 if !stored.contains(status) {
792 stored.push(status);
793 }
794 }
795 }
796 stored
797 }
798 _ => ALL_STATUSES.to_vec(),
799 };
800 let target_queues: Vec<Option<&str>> = match search_request.queues {
802 Some(ref names) => names.iter().map(|n| Some(n.as_str())).collect(),
803 None => vec![None],
804 };
805
806 let mut matching_jobs = Vec::new();
807 let mut seen = std::collections::HashSet::new();
808 for queue_name in target_queues {
809 let mut queue_jobs = Vec::new();
810 for status in &statuses {
811 queue_jobs.extend(try_api!(
812 queue
813 .jobs_with_status(queue_name, status, SEARCH_LIMIT)
814 .await,
815 "Failed to list jobs"
816 ));
817 }
818
819 for job in &queue_jobs {
820 if !seen.insert(job.id) || !job_matches_search(job, &search_term) {
821 continue;
822 }
823
824 if let Some(ref priorities) = search_request.priorities {
826 let job_priority = job.priority.to_string();
827 if !priorities
828 .iter()
829 .any(|p| p.eq_ignore_ascii_case(&job_priority))
830 {
831 continue;
832 }
833 }
834
835 if let Some(ref created_after) = search_request.created_after
837 && job.created_at < *created_after
838 {
839 continue;
840 }
841
842 if let Some(ref created_before) = search_request.created_before
843 && job.created_at > *created_before
844 {
845 continue;
846 }
847
848 matching_jobs.push(job_info(job));
849 }
850 }
851
852 matching_jobs.sort_by_key(|j| std::cmp::Reverse(j.created_at));
854
855 Ok(json_reply(&ApiResponse::success(paginate(
856 matching_jobs,
857 &pagination,
858 ))))
859}
860
861fn payload_search_text(payload: &serde_json::Value) -> String {
867 match serde_json::to_string(payload) {
868 Ok(text) => text.to_lowercase(),
869 Err(e) => {
870 tracing::warn!(error = %e, "failed to serialize job payload for search");
871 payload.to_string().to_lowercase()
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use crate::api::test_support::{body_json, unreachable_queue};
880
881 #[tokio::test]
882 async fn test_list_jobs_returns_500_when_database_is_down() {
883 let response = list_jobs_handler(
884 unreachable_queue(),
885 PaginationParams::default(),
886 serde_json::from_value(serde_json::json!({})).unwrap(),
887 SortParams {
888 sort_by: None,
889 sort_order: None,
890 },
891 )
892 .await
893 .unwrap()
894 .into_response();
895 let (status, body) = body_json(response).await;
896 assert_eq!(status, 500);
897 assert_eq!(body["success"], false);
898 assert!(
899 body["error"]
900 .as_str()
901 .unwrap()
902 .contains("Failed to get queue stats")
903 );
904 }
905
906 async fn listed_ids(
909 queue: Arc<hammerwork::queue::test::TestQueue>,
910 query: serde_json::Value,
911 ) -> Vec<String> {
912 let response = list_jobs_handler(
913 queue,
914 PaginationParams::default(),
915 serde_json::from_value(query.clone()).unwrap(),
916 serde_json::from_value(query).unwrap(),
917 )
918 .await
919 .unwrap()
920 .into_response();
921 let (status, body) = body_json(response).await;
922 assert_eq!(status, 200, "{body}");
923 body["data"]["items"]
924 .as_array()
925 .unwrap()
926 .iter()
927 .map(|item| item["id"].as_str().unwrap().to_string())
928 .collect()
929 }
930
931 async fn two_jobs() -> (
933 Arc<hammerwork::queue::test::TestQueue>,
934 String,
935 String,
936 chrono::DateTime<chrono::Utc>,
937 ) {
938 use hammerwork::{Job, queue::test::TestQueue};
939 let queue = Arc::new(TestQueue::new());
940 let first = queue
941 .enqueue(Job::new("q".into(), serde_json::json!({"name": "alpha"})))
942 .await
943 .unwrap();
944 let between = queue.clock().now() + chrono::Duration::seconds(30);
945 queue.clock().advance(chrono::Duration::minutes(1));
946 let second = queue
947 .enqueue(Job::new("q".into(), serde_json::json!({"name": "beta"})))
948 .await
949 .unwrap();
950 (queue, first.to_string(), second.to_string(), between)
951 }
952
953 #[tokio::test]
954 async fn test_list_jobs_applies_created_and_search_filters() {
955 let (queue, first, second, between) = two_jobs().await;
956 let ids = |query| listed_ids(queue.clone(), query);
957
958 assert_eq!(
959 ids(serde_json::json!({"queue": "q", "created_after": between})).await,
960 vec![second.clone()]
961 );
962 assert_eq!(
963 ids(serde_json::json!({"queue": "q", "created_before": between})).await,
964 vec![first.clone()]
965 );
966 assert_eq!(
967 ids(serde_json::json!({"queue": "q", "search": "ALPHA"})).await,
968 vec![first]
969 );
970 assert!(
971 ids(serde_json::json!({"queue": "q", "search": "no such job"}))
972 .await
973 .is_empty()
974 );
975 }
976
977 #[tokio::test]
978 async fn test_list_jobs_sort_order_applies_to_the_default_sort() {
979 let (queue, first, second, _) = two_jobs().await;
980 let ids = |query| listed_ids(queue.clone(), query);
981
982 assert_eq!(
984 ids(serde_json::json!({"queue": "q"})).await,
985 vec![second.clone(), first.clone()]
986 );
987 assert_eq!(
989 ids(serde_json::json!({"queue": "q", "sort_order": "asc"})).await,
990 vec![first.clone(), second.clone()]
991 );
992 assert_eq!(
994 ids(serde_json::json!({"queue": "q", "sort_by": "attempts", "sort_order": "ASC"}))
995 .await,
996 vec![first.clone(), second.clone()]
997 );
998 assert_eq!(
999 ids(serde_json::json!({"queue": "q", "sort_by": "created_at", "sort_order": "desc"}))
1000 .await,
1001 vec![second, first]
1002 );
1003 }
1004
1005 #[tokio::test]
1008 async fn test_list_jobs_filters_by_any_status() {
1009 use hammerwork::{Job, queue::test::TestQueue};
1010 let queue = Arc::new(TestQueue::new());
1011 let mk = |name: &str| Job::new("q".into(), serde_json::json!({ "name": name }));
1012 let ready = queue.enqueue(mk("ready")).await.unwrap().to_string();
1013 let later = queue
1014 .enqueue(Job::with_delay(
1015 "q".into(),
1016 serde_json::json!({"name": "later"}),
1017 chrono::Duration::hours(1),
1018 ))
1019 .await
1020 .unwrap()
1021 .to_string();
1022 let done = queue.enqueue(mk("done")).await.unwrap();
1023 let running = queue.enqueue(mk("running")).await.unwrap();
1024 let timed_out = queue.enqueue(mk("timed_out")).await.unwrap();
1025 let dead = queue.enqueue(mk("dead")).await.unwrap();
1026 let failed = queue.enqueue(mk("failed")).await.unwrap();
1027 let mut claimed = Vec::new();
1029 while let Some(job) = queue.dequeue("q").await.unwrap() {
1030 claimed.push(job.id);
1031 }
1032 for id in [done, running, timed_out, dead, failed] {
1033 assert!(claimed.contains(&id), "{id} was claimed");
1034 }
1035 let ready_id: hammerwork::JobId = ready.parse().unwrap();
1037 queue
1038 .retry_job(ready_id, queue.clock().now())
1039 .await
1040 .unwrap();
1041 queue.complete_job(done).await.unwrap();
1042 queue.mark_job_timed_out(timed_out, "slow").await.unwrap();
1043 queue.mark_job_dead(dead, "boom").await.unwrap();
1044 queue.fail_job(failed, "bad").await.unwrap();
1045
1046 let ids =
1047 |status: &'static str| listed_ids(queue.clone(), serde_json::json!({"status": status}));
1048 assert_eq!(ids("running").await, vec![running.to_string()]);
1049 assert_eq!(ids("completed").await, vec![done.to_string()]);
1050 assert_eq!(ids("dead").await, vec![dead.to_string()]);
1051 assert_eq!(ids("timedout").await, vec![timed_out.to_string()]);
1052 assert_eq!(ids("timed_out").await, vec![timed_out.to_string()]);
1053 let mut every_failure = ids("failed").await;
1054 every_failure.sort();
1055 let mut expected = vec![dead.to_string(), timed_out.to_string(), failed.to_string()];
1056 expected.sort();
1057 assert_eq!(every_failure, expected);
1058 let mut pending = ids("pending").await;
1060 pending.sort();
1061 let mut expected = vec![ready, later];
1062 expected.sort();
1063 assert_eq!(pending, expected);
1064 assert!(ids("no_such_status").await.is_empty());
1065 }
1066
1067 #[tokio::test]
1069 async fn test_search_reaches_every_status() {
1070 use hammerwork::{Job, queue::test::TestQueue};
1071 let queue = Arc::new(TestQueue::new());
1072 let now_job = queue
1073 .enqueue(Job::new("q".into(), serde_json::json!({"tag": "needle"})))
1074 .await
1075 .unwrap();
1076 let later = queue
1077 .enqueue(Job::with_delay(
1078 "q".into(),
1079 serde_json::json!({"tag": "needle"}),
1080 chrono::Duration::hours(1),
1081 ))
1082 .await
1083 .unwrap();
1084 let claimed = queue.dequeue("q").await.unwrap().unwrap();
1085 assert_eq!(claimed.id, now_job);
1086 queue.fail_job(now_job, "bad needle").await.unwrap();
1087
1088 let search = |body: serde_json::Value| {
1089 let queue = queue.clone();
1090 async move {
1091 let response = search_jobs_handler(
1092 queue,
1093 serde_json::from_value(body).unwrap(),
1094 PaginationParams::default(),
1095 )
1096 .await
1097 .unwrap()
1098 .into_response();
1099 let (status, body) = body_json(response).await;
1100 assert_eq!(status, 200, "{body}");
1101 let mut ids: Vec<String> = body["data"]["items"]
1102 .as_array()
1103 .unwrap()
1104 .iter()
1105 .map(|i| i["id"].as_str().unwrap().to_string())
1106 .collect();
1107 ids.sort();
1108 ids
1109 }
1110 };
1111 let mut both = vec![now_job.to_string(), later.to_string()];
1112 both.sort();
1113 assert_eq!(search(serde_json::json!({"query": "needle"})).await, both);
1114 assert_eq!(
1115 search(serde_json::json!({"query": "needle", "statuses": ["failed"]})).await,
1116 vec![now_job.to_string()]
1117 );
1118 assert_eq!(
1119 search(
1120 serde_json::json!({"query": "needle", "statuses": ["Pending"], "queues": ["q"]})
1121 )
1122 .await,
1123 vec![later.to_string()]
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn test_invalid_job_id_is_400() {
1129 let response = get_job_handler("not-a-uuid".to_string(), unreachable_queue())
1130 .await
1131 .unwrap()
1132 .into_response();
1133 let (status, _) = body_json(response).await;
1134 assert_eq!(status, 400);
1135 }
1136
1137 #[test]
1138 fn test_payload_search_text_lowercases_json() {
1139 let payload = serde_json::json!({"To": "User@Example.com"});
1140 let text = payload_search_text(&payload);
1141 assert!(text.contains("user@example.com"));
1142 assert!(text.contains("\"to\""));
1143 }
1144
1145 #[test]
1146 fn test_create_job_request_deserialization() {
1147 let json = r#"{
1148 "queue_name": "email",
1149 "payload": {"to": "user@example.com", "subject": "Hello"},
1150 "priority": "high",
1151 "max_attempts": 5
1152 }"#;
1153
1154 let request: CreateJobRequest = serde_json::from_str(json).unwrap();
1155 assert_eq!(request.queue_name, "email");
1156 assert_eq!(request.priority, Some("high".to_string()));
1157 assert_eq!(request.max_attempts, Some(5));
1158 }
1159
1160 #[test]
1161 fn test_job_action_request_deserialization() {
1162 let json = r#"{"action": "retry", "reason": "Network error resolved"}"#;
1163 let request: JobActionRequest = serde_json::from_str(json).unwrap();
1164 assert_eq!(request.action, "retry");
1165 assert_eq!(request.reason, Some("Network error resolved".to_string()));
1166 }
1167
1168 #[test]
1169 fn test_bulk_job_action_request() {
1170 let json = r#"{
1171 "job_ids": ["job-1", "job-2", "job-3"],
1172 "action": "delete",
1173 "reason": "Cleanup old jobs"
1174 }"#;
1175
1176 let request: BulkJobActionRequest = serde_json::from_str(json).unwrap();
1177 assert_eq!(request.job_ids.len(), 3);
1178 assert_eq!(request.action, "delete");
1179 }
1180}