Skip to main content

hammerwork_web/api/
mod.rs

1//! REST API endpoints for the Hammerwork web dashboard.
2//!
3//! This module provides a comprehensive REST API for job queue management,
4//! including endpoints for jobs, queues, statistics, and system information.
5//! All API responses use a standardized format with proper error handling.
6//!
7//! # API Response Format
8//!
9//! All API endpoints return responses in a consistent format:
10//!
11//! ```rust
12//! use hammerwork_web::api::ApiResponse;
13//! use serde_json::json;
14//!
15//! // Success response
16//! let success_response = ApiResponse::success(json!({"count": 42}));
17//! assert!(success_response.success);
18//! assert!(success_response.data.is_some());
19//! assert!(success_response.error.is_none());
20//!
21//! // Error response
22//! let error_response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
23//! assert!(!error_response.success);
24//! assert!(error_response.data.is_none());
25//! assert!(error_response.error.is_some());
26//! ```
27//!
28//! # Pagination
29//!
30//! Many endpoints support pagination using query parameters:
31//!
32//! ```rust
33//! use hammerwork_web::api::{PaginationParams, PaginationMeta};
34//!
35//! let params = PaginationParams {
36//!     page: Some(2),
37//!     limit: Some(50),
38//!     offset: None,
39//! };
40//!
41//! assert_eq!(params.get_limit(), 50);
42//! assert_eq!(params.get_offset(), 50); // (page-1) * limit
43//!
44//! let meta = PaginationMeta::new(&params, 200); // 200 total items
45//! assert_eq!(meta.page, 2);
46//! assert_eq!(meta.total_pages, 4);
47//! assert!(meta.has_next);
48//! assert!(meta.has_prev);
49//! ```
50
51/// Unwrap a `Result` inside a warp handler returning `Ok(reply)`; on error log it
52/// and return a `500` JSON error response instead of swallowing the failure.
53macro_rules! try_api {
54    ($expr:expr, $context:expr) => {
55        match $expr {
56            Ok(value) => value,
57            Err(e) => return Ok($crate::api::internal_error($context, &e)),
58        }
59    };
60}
61
62pub mod archive;
63pub mod history;
64pub mod jobs;
65pub mod queues;
66pub mod stats;
67pub mod system;
68
69use serde::{Deserialize, Serialize};
70use warp::Reply;
71
72/// Standard API response wrapper
73#[derive(Debug, Serialize)]
74pub struct ApiResponse<T> {
75    pub success: bool,
76    pub data: Option<T>,
77    pub error: Option<String>,
78    pub timestamp: chrono::DateTime<chrono::Utc>,
79}
80
81impl<T> ApiResponse<T> {
82    pub fn success(data: T) -> Self {
83        Self {
84            success: true,
85            data: Some(data),
86            error: None,
87            timestamp: chrono::Utc::now(),
88        }
89    }
90
91    pub fn error(message: String) -> Self {
92        Self {
93            success: false,
94            data: None,
95            error: Some(message),
96            timestamp: chrono::Utc::now(),
97        }
98    }
99}
100
101/// Serialize `body` as a `200 OK` JSON response.
102pub fn json_reply<T: Serialize>(body: &T) -> warp::reply::Response {
103    warp::reply::with_status(warp::reply::json(body), warp::http::StatusCode::OK).into_response()
104}
105
106/// Build a JSON [`ApiResponse`] error body with the given HTTP status.
107pub fn error_reply(
108    status: warp::http::StatusCode,
109    message: impl Into<String>,
110) -> warp::reply::Response {
111    let body = ApiResponse::<()>::error(message.into());
112    warp::reply::with_status(warp::reply::json(&body), status).into_response()
113}
114
115/// Log a failed backend operation and return it as a `500 Internal Server Error`
116/// JSON response (`"{context}: {err}"`).
117///
118/// Handlers use this instead of swallowing the error, so API callers can tell
119/// an outage apart from "no data".
120pub fn internal_error(context: &str, err: &dyn std::fmt::Display) -> warp::reply::Response {
121    tracing::error!(error = %err, "{}", context);
122    error_reply(
123        warp::http::StatusCode::INTERNAL_SERVER_ERROR,
124        format!("{}: {}", context, err),
125    )
126}
127
128/// Turn a failed queue operation into a JSON error reply: a missing job is a `404`, a
129/// transition the job's status does not allow a `409`, anything else a logged `500`.
130pub fn queue_error_reply(
131    context: &str,
132    err: &hammerwork::HammerworkError,
133) -> warp::reply::Response {
134    use hammerwork::HammerworkError;
135    match err {
136        HammerworkError::JobNotFound { .. } => error_reply(
137            warp::http::StatusCode::NOT_FOUND,
138            format!("{}: {}", context, err),
139        ),
140        HammerworkError::InvalidJobTransition { .. } => error_reply(
141            warp::http::StatusCode::CONFLICT,
142            format!("{}: {}", context, err),
143        ),
144        _ => internal_error(context, err),
145    }
146}
147
148/// Pagination parameters
149#[derive(Debug, Deserialize)]
150pub struct PaginationParams {
151    pub page: Option<u32>,
152    pub limit: Option<u32>,
153    pub offset: Option<u32>,
154}
155
156impl Default for PaginationParams {
157    fn default() -> Self {
158        Self {
159            page: Some(1),
160            limit: Some(50),
161            offset: None,
162        }
163    }
164}
165
166/// The page size when a request does not give one.
167pub const DEFAULT_PAGE_SIZE: u32 = 50;
168
169/// The largest page size a request may ask for; larger limits are clamped to it.
170pub const MAX_PAGE_SIZE: u32 = 1000;
171
172impl PaginationParams {
173    /// The offset of the first item: `offset` if given, else `(page - 1) * limit` computed
174    /// with the clamped [`get_limit`](Self::get_limit), saturating instead of overflowing.
175    pub fn get_offset(&self) -> u32 {
176        if let Some(offset) = self.offset {
177            offset
178        } else {
179            let page = self.page.unwrap_or(1);
180            page.saturating_sub(1).saturating_mul(self.get_limit())
181        }
182    }
183
184    /// The page size: `limit`, clamped to `1..=`[`MAX_PAGE_SIZE`].
185    pub fn get_limit(&self) -> u32 {
186        self.limit
187            .unwrap_or(DEFAULT_PAGE_SIZE)
188            .clamp(1, MAX_PAGE_SIZE)
189    }
190}
191
192/// The jobs of a queue that failed: those in status `Failed` (a failed dependency, a
193/// fail-fast batch or workflow, `fail_job`), `Dead` (out of retries) and `TimedOut`.
194///
195/// Every "failed" figure of the dashboard (REST and WebSocket) is this count;
196/// `dead_count` reports the `Dead` jobs separately.
197pub(crate) fn failed_job_count(stats: &hammerwork::stats::QueueStats) -> u64 {
198    stats
199        .statistics
200        .failed
201        .saturating_add(stats.dead_count)
202        .saturating_add(stats.timed_out_count)
203}
204
205/// Pagination metadata for responses
206#[derive(Debug, Serialize)]
207pub struct PaginationMeta {
208    pub page: u32,
209    pub limit: u32,
210    pub offset: u32,
211    pub total: u64,
212    pub total_pages: u32,
213    pub has_next: bool,
214    pub has_prev: bool,
215}
216
217impl PaginationMeta {
218    pub fn new(params: &PaginationParams, total: u64) -> Self {
219        let limit = params.get_limit();
220        let offset = params.get_offset();
221        // An explicit offset decides which items were served, whatever `page` says, so
222        // the page number and the neighbours are derived from it.
223        let page = match params.offset {
224            Some(offset) => (offset / limit).saturating_add(1),
225            None => params.page.unwrap_or(1),
226        };
227        let total_pages = u32::try_from(total.div_ceil(u64::from(limit))).unwrap_or(u32::MAX);
228
229        Self {
230            page,
231            limit,
232            offset,
233            total,
234            total_pages,
235            has_next: u64::from(offset) + u64::from(limit) < total,
236            has_prev: offset > 0,
237        }
238    }
239}
240
241/// Paginated response wrapper
242#[derive(Debug, Serialize)]
243pub struct PaginatedResponse<T> {
244    pub items: Vec<T>,
245    pub pagination: PaginationMeta,
246}
247
248/// Query filter parameters
249#[derive(Debug, Deserialize, Default)]
250pub struct FilterParams {
251    pub status: Option<String>,
252    pub priority: Option<String>,
253    pub queue: Option<String>,
254    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
255    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
256    pub search: Option<String>,
257}
258
259/// Sort parameters
260#[derive(Debug, Deserialize, Default)]
261pub struct SortParams {
262    pub sort_by: Option<String>,
263    pub sort_order: Option<String>,
264}
265
266impl SortParams {
267    /// Whether `sort_order` asks for ascending order (`asc`, in any case); anything else
268    /// is descending.
269    pub fn is_ascending(&self) -> bool {
270        self.sort_order
271            .as_deref()
272            .is_some_and(|order| order.trim().eq_ignore_ascii_case("asc"))
273    }
274
275    pub fn get_order_by(&self) -> (String, String) {
276        let field = self.sort_by.as_deref().unwrap_or("created_at").to_string();
277        let direction = if self.is_ascending() { "ASC" } else { "DESC" }.to_string();
278        (field, direction)
279    }
280}
281
282/// Common error handling for API endpoints
283pub async fn handle_api_error(
284    err: warp::Rejection,
285) -> Result<impl warp::Reply, std::convert::Infallible> {
286    let response = if err.is_not_found() {
287        ApiResponse::<()>::error("Resource not found".to_string())
288    } else if err
289        .find::<warp::filters::body::BodyDeserializeError>()
290        .is_some()
291    {
292        ApiResponse::<()>::error("Invalid request body".to_string())
293    } else if err.find::<warp::reject::InvalidQuery>().is_some() {
294        ApiResponse::<()>::error("Invalid query parameters".to_string())
295    } else {
296        ApiResponse::<()>::error("Internal server error".to_string())
297    };
298
299    let status = if err.is_not_found() {
300        warp::http::StatusCode::NOT_FOUND
301    } else if err
302        .find::<warp::filters::body::BodyDeserializeError>()
303        .is_some()
304        || err.find::<warp::reject::InvalidQuery>().is_some()
305    {
306        warp::http::StatusCode::BAD_REQUEST
307    } else {
308        warp::http::StatusCode::INTERNAL_SERVER_ERROR
309    };
310
311    Ok(warp::reply::with_status(
312        warp::reply::json(&response),
313        status,
314    ))
315}
316
317/// Hand a clone of shared state (a queue handle, system state, ...) to a handler.
318pub fn with_shared<T>(
319    value: T,
320) -> impl warp::Filter<Extract = (T,), Error = std::convert::Infallible> + Clone
321where
322    T: Clone + Send,
323{
324    use warp::Filter;
325    warp::any().map(move || value.clone())
326}
327
328/// Extract pagination parameters from query string
329pub fn with_pagination()
330-> impl warp::Filter<Extract = (PaginationParams,), Error = warp::Rejection> + Clone {
331    warp::query::<PaginationParams>()
332}
333
334/// Extract filter parameters from query string  
335pub fn with_filters()
336-> impl warp::Filter<Extract = (FilterParams,), Error = warp::Rejection> + Clone {
337    warp::query::<FilterParams>()
338}
339
340/// Extract sort parameters from query string
341pub fn with_sort() -> impl warp::Filter<Extract = (SortParams,), Error = warp::Rejection> + Clone {
342    warp::query::<SortParams>()
343}
344
345/// Test helpers shared by the handler tests of the API submodules.
346#[cfg(test)]
347pub(crate) mod test_support {
348    use hammerwork::JobQueue;
349    use std::sync::Arc;
350
351    /// A queue whose database is unreachable, so every query fails quickly.
352    pub fn unreachable_queue() -> Arc<JobQueue<sqlx::Postgres>> {
353        let pool = sqlx::postgres::PgPoolOptions::new()
354            .acquire_timeout(std::time::Duration::from_millis(200))
355            .connect_lazy("postgres://nobody:nothing@127.0.0.1:1/none")
356            .expect("lazy pool");
357        Arc::new(JobQueue::new(pool))
358    }
359
360    /// The mock queue as a `JobHistory`, for handler tests that need no database: only
361    /// `jobs_with_status` does anything.
362    impl super::history::JobHistory for hammerwork::queue::test::TestQueue {
363        async fn hourly_activity(
364            &self,
365            _queue: Option<&str>,
366            _start: chrono::DateTime<chrono::Utc>,
367            _end: chrono::DateTime<chrono::Utc>,
368        ) -> hammerwork::Result<Vec<super::history::HourBucket>> {
369            Ok(Vec::new())
370        }
371
372        async fn error_groups(
373            &self,
374            _since: chrono::DateTime<chrono::Utc>,
375            _limit: u32,
376        ) -> hammerwork::Result<Vec<super::history::ErrorGroup>> {
377            Ok(Vec::new())
378        }
379
380        async fn count_jobs(
381            &self,
382            _queue: Option<&str>,
383            _kind: super::history::FinishedKind,
384            _older_than: Option<chrono::DateTime<chrono::Utc>>,
385        ) -> hammerwork::Result<u64> {
386            Ok(0)
387        }
388
389        async fn delete_jobs(
390            &self,
391            _queue: Option<&str>,
392            _kind: super::history::FinishedKind,
393            _older_than: Option<chrono::DateTime<chrono::Utc>>,
394        ) -> hammerwork::Result<u64> {
395            Ok(0)
396        }
397
398        async fn archived_jobs(
399            &self,
400            _filter: &super::archive::ArchiveFilterParams,
401            _limit: u32,
402            _offset: u32,
403        ) -> hammerwork::Result<(Vec<hammerwork::archive::ArchivedJob>, u64)> {
404            Ok((Vec::new(), 0))
405        }
406
407        async fn database_now(&self) -> hammerwork::Result<chrono::DateTime<chrono::Utc>> {
408            Ok(self.clock().now())
409        }
410
411        async fn recent_job_changes(
412            &self,
413            _since: chrono::DateTime<chrono::Utc>,
414            _limit: u32,
415        ) -> hammerwork::Result<Vec<crate::websocket::JobUpdate>> {
416            Ok(Vec::new())
417        }
418
419        async fn table_maintenance(
420            &self,
421            _operation: super::history::TableMaintenance,
422            _tables: &[&'static str],
423            _dry_run: bool,
424        ) -> hammerwork::Result<Vec<super::history::MaintenanceStatement>> {
425            Ok(Vec::new())
426        }
427
428        async fn jobs_with_status(
429            &self,
430            queue: Option<&str>,
431            status: &str,
432            limit: u32,
433        ) -> hammerwork::Result<Vec<hammerwork::Job>> {
434            let mut jobs: Vec<_> = self
435                .get_all_jobs()
436                .await
437                .into_iter()
438                .filter(|job| job.status.as_str() == status)
439                .filter(|job| queue.is_none_or(|name| job.queue_name == name))
440                .collect();
441            jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at));
442            jobs.truncate(limit as usize);
443            Ok(jobs)
444        }
445    }
446
447    /// Status code and JSON body of a handler reply.
448    pub async fn body_json(response: warp::reply::Response) -> (u16, serde_json::Value) {
449        use warp::Filter;
450        // `warp::test` needs a filter; hand the response out once via a shared slot.
451        let slot = Arc::new(std::sync::Mutex::new(Some(response)));
452        let filter = warp::any().map(move || slot.lock().unwrap().take().unwrap());
453        let reply = warp::test::request().reply(&filter).await;
454        (
455            reply.status().as_u16(),
456            serde_json::from_slice(reply.body()).unwrap(),
457        )
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::test_support::body_json;
464    use super::*;
465
466    #[test]
467    fn test_api_response_success() {
468        let response = ApiResponse::success("test data");
469        assert!(response.success);
470        assert_eq!(response.data, Some("test data"));
471        assert!(response.error.is_none());
472    }
473
474    #[test]
475    fn test_api_response_error() {
476        let response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
477        assert!(!response.success);
478        assert!(response.data.is_none());
479        assert_eq!(response.error, Some("Something went wrong".to_string()));
480    }
481
482    #[test]
483    fn test_pagination_params_defaults() {
484        let params = PaginationParams::default();
485        assert_eq!(params.get_limit(), 50);
486        assert_eq!(params.get_offset(), 0);
487    }
488
489    #[test]
490    fn test_pagination_params_calculation() {
491        let params = PaginationParams {
492            page: Some(3),
493            limit: Some(20),
494            offset: None,
495        };
496        assert_eq!(params.get_limit(), 20);
497        assert_eq!(params.get_offset(), 40); // (3-1) * 20
498    }
499
500    #[test]
501    fn the_offset_uses_the_clamped_limit_and_never_overflows() {
502        // M13: limit=5000&page=2 serves at most 1000 rows, so page 2 starts at row 1000.
503        let params = PaginationParams {
504            page: Some(2),
505            limit: Some(5000),
506            offset: None,
507        };
508        assert_eq!(params.get_limit(), MAX_PAGE_SIZE);
509        assert_eq!(params.get_offset(), MAX_PAGE_SIZE);
510
511        let huge = PaginationParams {
512            page: Some(u32::MAX),
513            limit: Some(u32::MAX),
514            offset: None,
515        };
516        assert_eq!(
517            huge.get_offset(),
518            u32::MAX,
519            "saturates instead of overflowing"
520        );
521
522        let zero = PaginationParams {
523            page: Some(0),
524            limit: Some(0),
525            offset: None,
526        };
527        assert_eq!(zero.get_limit(), 1, "a zero limit is clamped to one");
528        assert_eq!(zero.get_offset(), 0);
529        let meta = PaginationMeta::new(&zero, 3);
530        assert_eq!(meta.total_pages, 3);
531
532        let explicit = PaginationParams {
533            page: Some(7),
534            limit: Some(10),
535            offset: Some(5),
536        };
537        assert_eq!(explicit.get_offset(), 5, "an explicit offset wins");
538    }
539
540    #[test]
541    fn test_pagination_meta() {
542        let params = PaginationParams {
543            page: Some(2),
544            limit: Some(10),
545            offset: None,
546        };
547        let meta = PaginationMeta::new(&params, 45);
548
549        assert_eq!(meta.page, 2);
550        assert_eq!(meta.limit, 10);
551        assert_eq!(meta.total, 45);
552        assert_eq!(meta.total_pages, 5);
553        assert!(meta.has_next);
554        assert!(meta.has_prev);
555    }
556
557    #[test]
558    fn test_pagination_meta_describes_an_explicit_offset() {
559        let last_page = PaginationParams {
560            page: None,
561            limit: Some(50),
562            offset: Some(100),
563        };
564        let meta = PaginationMeta::new(&last_page, 120);
565        assert_eq!(meta.page, 3);
566        assert_eq!(meta.offset, 100);
567        assert!(!meta.has_next);
568        assert!(meta.has_prev);
569
570        // The offset wins over a page that disagrees with it
571        let first_items = PaginationParams {
572            page: Some(3),
573            limit: Some(50),
574            offset: Some(0),
575        };
576        let meta = PaginationMeta::new(&first_items, 120);
577        assert_eq!(meta.page, 1);
578        assert!(meta.has_next);
579        assert!(!meta.has_prev);
580    }
581
582    #[test]
583    fn test_sort_params_defaults() {
584        let params = SortParams {
585            sort_by: None,
586            sort_order: None,
587        };
588        let (field, direction) = params.get_order_by();
589        assert_eq!(field, "created_at");
590        assert_eq!(direction, "DESC");
591    }
592
593    /// #94: `asc` selects ascending order in any case (the jobs list only accepted
594    /// lowercase); everything else is descending.
595    #[test]
596    fn sort_order_is_case_insensitive() {
597        let order = |order: Option<&str>| SortParams {
598            sort_by: Some("created_at".to_string()),
599            sort_order: order.map(str::to_string),
600        };
601        for ascending in ["asc", "ASC", "Asc", " asc "] {
602            assert!(order(Some(ascending)).is_ascending(), "{ascending}");
603            assert_eq!(order(Some(ascending)).get_order_by().1, "ASC");
604        }
605        for descending in [None, Some("desc"), Some("DESC"), Some("ascending")] {
606            assert!(!order(descending).is_ascending(), "{descending:?}");
607            assert_eq!(order(descending).get_order_by().1, "DESC");
608        }
609    }
610
611    #[test]
612    fn test_sort_params_custom() {
613        let params = SortParams {
614            sort_by: Some("name".to_string()),
615            sort_order: Some("asc".to_string()),
616        };
617        let (field, direction) = params.get_order_by();
618        assert_eq!(field, "name");
619        assert_eq!(direction, "ASC");
620    }
621
622    #[tokio::test]
623    async fn test_internal_error_is_500_with_json_error_body() {
624        let (status, body) = body_json(internal_error("Failed to list jobs", &"db down")).await;
625        assert_eq!(status, 500);
626        assert_eq!(body["success"], false);
627        assert_eq!(body["error"], "Failed to list jobs: db down");
628        assert!(body["data"].is_null());
629    }
630
631    #[tokio::test]
632    async fn test_error_reply_uses_given_status() {
633        let (status, body) =
634            body_json(error_reply(warp::http::StatusCode::BAD_REQUEST, "nope")).await;
635        assert_eq!(status, 400);
636        assert_eq!(body["error"], "nope");
637    }
638
639    #[tokio::test]
640    async fn test_json_reply_is_200() {
641        let (status, body) = body_json(json_reply(&ApiResponse::success(5))).await;
642        assert_eq!(status, 200);
643        assert_eq!(body["data"], 5);
644    }
645}
646
647/// #71: the listing endpoints skip rows they cannot decode instead of failing the whole
648/// page with a 500; reading the corrupt job itself still fails. The corrupt rows are
649/// written with raw SQL and removed at the end.
650#[cfg(test)]
651mod undecodable_row_tests {
652    use super::history::JobHistory;
653    use hammerwork::archive::{ArchivalConfig, ArchivalPolicy, ArchivalReason};
654    use hammerwork::{Job, JobId};
655    use serde_json::{Value, json};
656    use std::future::Future;
657    use std::sync::Arc;
658    use warp::Filter;
659
660    /// The ids listed in a paginated response, sorted.
661    fn listed(body: &Value) -> Vec<String> {
662        let mut ids: Vec<String> = body["data"]["items"]
663            .as_array()
664            .unwrap_or_else(|| panic!("no items: {body}"))
665            .iter()
666            .map(|item| item["id"].as_str().unwrap().to_string())
667            .collect();
668        ids.sort();
669        ids
670    }
671
672    /// `sql` runs one statement. `corrupt_archive_id` makes the archived row undecodable
673    /// by giving it an id that is not a UUID (possible on MySQL, where the id is text).
674    async fn listings_skip_undecodable_rows<Q, S, F>(
675        queue: Arc<Q>,
676        sql: S,
677        corrupt_archive_id: bool,
678    ) where
679        Q: JobHistory + 'static,
680        S: Fn(String) -> F,
681        F: Future<Output = ()>,
682    {
683        let routes = super::jobs::routes(queue.clone())
684            .or(super::queues::routes(queue.clone()))
685            .or(super::archive::archive_routes(queue.clone()));
686        let get = |path: String| {
687            let routes = routes.clone();
688            async move {
689                let response = warp::test::request().path(&path).reply(&routes).await;
690                let body: Value = serde_json::from_slice(response.body()).unwrap();
691                (response.status().as_u16(), body)
692            }
693        };
694
695        let tag = uuid::Uuid::new_v4().simple().to_string();
696        let name = format!("undecodable_{tag}");
697        let archive_name = format!("undecodable_archive_{tag}");
698        let enqueue = |queue_name: &str| queue.enqueue(Job::new(queue_name.to_string(), json!({})));
699        let break_timeout = |id: JobId| {
700            sql(format!(
701                "UPDATE hammerwork_jobs SET timeout_seconds = -1 WHERE id = '{id}'"
702            ))
703        };
704
705        let ready = enqueue(&name).await.unwrap();
706        let bad_ready = enqueue(&name).await.unwrap();
707        break_timeout(bad_ready).await;
708        let dead = enqueue(&name).await.unwrap();
709        let bad_dead = enqueue(&name).await.unwrap();
710        for id in [dead, bad_dead] {
711            queue.mark_job_dead(id, "gone").await.unwrap();
712        }
713        break_timeout(bad_dead).await;
714        let mut healthy = vec![ready.to_string(), dead.to_string()];
715        healthy.sort();
716
717        // Job list, queue jobs and search: the healthy jobs, without a 500.
718        for path in [
719            format!("/jobs?queue={name}&limit=100"),
720            format!("/queues/{name}/jobs?limit=100"),
721        ] {
722            let (status, body) = get(path.clone()).await;
723            assert_eq!(status, 200, "{path}: {body}");
724            assert_eq!(listed(&body), healthy, "{path}");
725        }
726        let response = warp::test::request()
727            .method("POST")
728            .path("/jobs/search?limit=100")
729            .json(&json!({"query": "", "queues": [name]}))
730            .reply(&routes)
731            .await;
732        let body: Value = serde_json::from_slice(response.body()).unwrap();
733        assert_eq!(response.status(), 200, "{body}");
734        assert_eq!(listed(&body), healthy, "search");
735
736        // The corrupt job itself still reports the error.
737        let (status, body) = get(format!("/jobs/{bad_ready}")).await;
738        assert_eq!(status, 500, "{body}");
739        assert!(
740            body["error"].as_str().unwrap().contains("timeout_seconds"),
741            "{body}"
742        );
743
744        // Archived jobs
745        let archived = enqueue(&archive_name).await.unwrap();
746        let bad_archived = enqueue(&archive_name).await.unwrap();
747        for id in [archived, bad_archived] {
748            queue.mark_job_dead(id, "gone").await.unwrap();
749        }
750        let policy = ArchivalPolicy::new()
751            .archive_dead_after(chrono::Duration::seconds(0))
752            .enabled(true);
753        queue
754            .archive_jobs(
755                Some(&archive_name),
756                &policy,
757                &ArchivalConfig::new(),
758                ArchivalReason::Manual,
759                None,
760            )
761            .await
762            .unwrap();
763        if corrupt_archive_id {
764            sql(format!(
765                "UPDATE hammerwork_jobs_archive SET id = 'not-a-uuid' WHERE id = '{bad_archived}'"
766            ))
767            .await;
768        }
769        let (status, body) = get(format!("/archive/jobs?queue={archive_name}")).await;
770        assert_eq!(status, 200, "{body}");
771        let ids = listed(&body);
772        assert!(ids.contains(&archived.to_string()), "{body}");
773        if corrupt_archive_id {
774            assert_eq!(ids, [archived.to_string()], "{body}");
775        }
776
777        for (table, queue_name) in [
778            ("hammerwork_jobs", &name),
779            ("hammerwork_jobs_archive", &archive_name),
780        ] {
781            sql(format!(
782                "DELETE FROM {table} WHERE queue_name = '{queue_name}'"
783            ))
784            .await;
785        }
786    }
787
788    #[tokio::test]
789    #[ignore = "requires DATABASE_URL (PostgreSQL)"]
790    async fn postgres_listings_skip_undecodable_rows() {
791        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
792        let pool = sqlx::PgPool::connect(&url).await.unwrap();
793        let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
794        let sql = |statement: String| {
795            let pool = pool.clone();
796            async move {
797                sqlx::query(&statement).execute(&pool).await.unwrap();
798            }
799        };
800        listings_skip_undecodable_rows(queue, sql, false).await;
801    }
802
803    #[tokio::test]
804    #[ignore = "requires MYSQL_DATABASE_URL"]
805    async fn mysql_listings_skip_undecodable_rows() {
806        let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
807        let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
808        let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
809        let sql = |statement: String| {
810            let pool = pool.clone();
811            async move {
812                sqlx::query(&statement).execute(&pool).await.unwrap();
813            }
814        };
815        listings_skip_undecodable_rows(queue, sql, true).await;
816    }
817}