1mod error;
45mod inputs;
46mod registration;
47mod schedule_spec;
48mod sync;
49mod sync_schedules;
50mod types;
51mod workflow;
52
53pub use error::CatalogError;
54pub use inputs::{CatalogJobEnqueueInput, CatalogJobScheduleInput};
55pub use schedule_spec::CatalogJobScheduleSpec;
56pub use types::{
57 JobCatalog, JobCatalogDefaults, JobCatalogDefinitionOverrides, JobCatalogExactSyncReport,
58 JobCatalogScheduleSyncReport, JobCatalogScheduleSyncScope, JobCatalogSyncReport,
59 JobCatalogSyncScope,
60};
61pub use workflow::CatalogWorkflowDagBuilder;
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66 use async_trait::async_trait;
67 use runledger_core::jobs::{
68 JobCompletion, JobContext, JobFailure, JobHandler, JobType, WorkflowBuildError,
69 };
70 use serde_json::Value;
71 use serde_json::json;
72
73 struct StaticHandler(&'static str);
74
75 #[async_trait]
76 impl JobHandler for StaticHandler {
77 fn job_type(&self) -> JobType<'static> {
78 JobType::new(self.0)
79 }
80
81 async fn execute(
82 &self,
83 _context: JobContext,
84 _payload: Value,
85 ) -> Result<JobCompletion, JobFailure> {
86 Ok(JobCompletion::success())
87 }
88 }
89
90 struct BlankHandler;
91
92 #[async_trait]
93 impl JobHandler for BlankHandler {
94 fn job_type(&self) -> JobType<'static> {
95 JobType::new(" ")
96 }
97
98 async fn execute(
99 &self,
100 _context: JobContext,
101 _payload: Value,
102 ) -> Result<JobCompletion, JobFailure> {
103 Ok(JobCompletion::success())
104 }
105 }
106
107 struct MismatchHandler;
108
109 #[async_trait]
110 impl JobHandler for MismatchHandler {
111 fn job_type(&self) -> JobType<'static> {
112 JobType::new("jobs.other")
113 }
114
115 async fn execute(
116 &self,
117 _context: JobContext,
118 _payload: Value,
119 ) -> Result<JobCompletion, JobFailure> {
120 Ok(JobCompletion::success())
121 }
122 }
123
124 #[test]
125 fn rejects_blank_declared_job_type() {
126 let error = JobCatalog::new()
127 .try_job(" ", StaticHandler("jobs.test"))
128 .expect_err("blank declared job type");
129 assert!(matches!(
130 error,
131 CatalogError::InvalidJobType {
132 job_type,
133 source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
134 } if job_type == " "
135 ));
136 }
137
138 #[test]
139 fn rejects_blank_handler_job_type() {
140 let error = JobCatalog::new()
141 .try_job("jobs.test", BlankHandler)
142 .expect_err("blank handler job type");
143 assert!(matches!(
144 error,
145 CatalogError::InvalidHandlerJobType {
146 handler_job_type,
147 source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
148 } if handler_job_type == " "
149 ));
150 }
151
152 #[test]
153 fn rejects_handler_job_type_mismatch() {
154 let error = JobCatalog::new()
155 .try_job("jobs.catalog.expected", MismatchHandler)
156 .expect_err("handler mismatch");
157 assert!(matches!(error, CatalogError::HandlerJobTypeMismatch { .. }));
158 }
159
160 #[test]
161 fn rejects_duplicate_job_type() {
162 let error = JobCatalog::new()
163 .try_job("jobs.dup", StaticHandler("jobs.dup"))
164 .expect("first registration")
165 .try_job("jobs.dup", StaticHandler("jobs.dup"))
166 .expect_err("duplicate job type");
167 assert!(matches!(error, CatalogError::DuplicateJobType { .. }));
168 }
169
170 #[test]
171 fn to_registry_preserves_handlers_and_retry_overrides() {
172 let catalog = JobCatalog::new()
173 .job("jobs.test", StaticHandler("jobs.test"))
174 .try_retry_delay_override("jobs.test", "job.test.wait", 42)
175 .expect("retry override");
176 let registry = catalog.to_registry();
177 assert!(registry.get(JobType::new("jobs.test")).is_some());
178 assert_eq!(
179 registry.retry_delay_override(JobType::new("jobs.test"), "job.test.wait"),
180 Some(42)
181 );
182 }
183
184 #[test]
185 fn retry_override_rejects_unknown_job_type() {
186 let error = JobCatalog::new()
187 .try_retry_delay_override("jobs.missing", "job.test.wait", 42)
188 .expect_err("unknown job type");
189 assert!(matches!(error, CatalogError::UnknownJobType { .. }));
190 }
191
192 #[test]
193 fn retry_override_validates_job_type_before_override_values() {
194 let error = JobCatalog::new()
195 .try_retry_delay_override("jobs.missing", " ", 0)
196 .expect_err("unknown job type");
197 assert!(matches!(error, CatalogError::UnknownJobType { .. }));
198 }
199
200 #[test]
201 fn retry_override_rejects_blank_failure_code() {
202 let error = JobCatalog::new()
203 .job("jobs.test", StaticHandler("jobs.test"))
204 .try_retry_delay_override("jobs.test", " ", 42)
205 .expect_err("blank failure code");
206 assert!(matches!(error, CatalogError::InvalidFailureCode));
207 }
208
209 #[test]
210 fn retry_override_rejects_non_positive_delay() {
211 let error = JobCatalog::new()
212 .job("jobs.test", StaticHandler("jobs.test"))
213 .try_retry_delay_override("jobs.test", "job.test.wait", 0)
214 .expect_err("invalid retry delay");
215 assert!(matches!(error, CatalogError::InvalidRetryDelay));
216 }
217
218 #[test]
219 fn exact_sync_scope_rejects_blank_job_type() {
220 let error = JobCatalogSyncScope::job_type(" ").expect_err("blank exact sync job type");
221 assert!(matches!(
222 error,
223 CatalogError::InvalidExactSyncScopeJobType {
224 job_type,
225 source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
226 } if job_type == " "
227 ));
228 }
229
230 #[test]
231 fn exact_sync_scope_rejects_empty_job_type_list() {
232 let error = JobCatalogSyncScope::job_types(Vec::<String>::new())
233 .expect_err("empty exact sync scope");
234 assert!(matches!(error, CatalogError::InvalidExactSyncScope));
235 }
236
237 #[test]
238 fn job_enqueue_rejects_unknown_job_type() {
239 let catalog = JobCatalog::new();
240 let error = catalog
241 .job_enqueue(&CatalogJobEnqueueInput {
242 job_type: "jobs.missing",
243 organization_id: None,
244 payload: &json!({}),
245 priority: None,
246 max_attempts: None,
247 timeout_seconds: None,
248 next_run_at: None,
249 idempotency_key: None,
250 stage: None,
251 })
252 .expect_err("unknown job type");
253 assert!(matches!(error, CatalogError::UnknownJobType { .. }));
254 }
255
256 #[test]
257 fn job_enqueue_rejects_disabled_catalog_defaults() {
258 let catalog = JobCatalog::new()
259 .job("jobs.test", StaticHandler("jobs.test"))
260 .defaults(JobCatalogDefaults::new().enabled(false));
261 let error = catalog
262 .job_enqueue(&CatalogJobEnqueueInput {
263 job_type: "jobs.test",
264 organization_id: None,
265 payload: &json!({}),
266 priority: None,
267 max_attempts: None,
268 timeout_seconds: None,
269 next_run_at: None,
270 idempotency_key: None,
271 stage: None,
272 })
273 .expect_err("disabled job type");
274 assert!(matches!(error, CatalogError::DisabledJobType { .. }));
275 }
276
277 #[test]
278 fn job_enqueue_rejects_disabled_job_definition_overrides() {
279 let catalog = JobCatalog::new().job_with_definition_overrides(
280 "jobs.test",
281 StaticHandler("jobs.test"),
282 JobCatalogDefinitionOverrides::new().enabled(false),
283 );
284 let error = catalog
285 .job_enqueue(&CatalogJobEnqueueInput {
286 job_type: "jobs.test",
287 organization_id: None,
288 payload: &json!({}),
289 priority: None,
290 max_attempts: None,
291 timeout_seconds: None,
292 next_run_at: None,
293 idempotency_key: None,
294 stage: None,
295 })
296 .expect_err("disabled job type");
297 assert!(matches!(error, CatalogError::DisabledJobType { .. }));
298 }
299
300 #[test]
301 fn job_definition_overrides_take_precedence_over_disabled_catalog_defaults() {
302 let catalog = JobCatalog::new()
303 .job_with_definition_overrides(
304 "jobs.test",
305 StaticHandler("jobs.test"),
306 JobCatalogDefinitionOverrides::new().enabled(true),
307 )
308 .defaults(JobCatalogDefaults::new().enabled(false));
309 let payload = json!({});
310 let enqueue = catalog
311 .job_enqueue(&CatalogJobEnqueueInput {
312 job_type: "jobs.test",
313 organization_id: None,
314 payload: &payload,
315 priority: None,
316 max_attempts: None,
317 timeout_seconds: None,
318 next_run_at: None,
319 idempotency_key: None,
320 stage: None,
321 })
322 .expect("job-specific enabled override should win");
323 assert_eq!(enqueue.job_type.as_str(), "jobs.test");
324 }
325
326 #[test]
327 fn job_schedule_accepts_enabled_job_definition_override() {
328 let catalog = JobCatalog::new()
329 .job_with_definition_overrides(
330 "jobs.test",
331 StaticHandler("jobs.test"),
332 JobCatalogDefinitionOverrides::new().enabled(true),
333 )
334 .defaults(JobCatalogDefaults::new().enabled(false));
335 let payload = json!({});
336 let schedule = catalog
337 .job_schedule(&CatalogJobScheduleInput {
338 name: "jobs.test.schedule",
339 job_type: "jobs.test",
340 organization_id: None,
341 payload_template: &payload,
342 cron_expr: "0 * * * * *",
343 is_active: true,
344 next_fire_at: chrono::Utc::now(),
345 max_jitter_seconds: 0,
346 })
347 .expect("job-specific enabled override should win");
348 assert_eq!(schedule.job_type.as_str(), "jobs.test");
349 }
350
351 #[test]
352 fn try_schedule_rejects_invalid_cron_before_database() {
353 let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
354 let payload = json!({});
355 let error = catalog
356 .try_schedule(CatalogJobScheduleSpec {
357 name: "jobs.test.schedule",
358 job_type: "jobs.test",
359 cron_expr: "not a cron expression",
360 payload_template: &payload,
361 is_active: true,
362 organization_id: None,
363 max_jitter_seconds: 0,
364 next_fire_at: None,
365 })
366 .expect_err("invalid cron");
367 assert!(matches!(
368 error,
369 CatalogError::InvalidScheduleSpec {
370 field: "cron_expr",
371 ..
372 }
373 ));
374 }
375
376 #[test]
377 fn try_schedule_rejects_excessive_jitter_before_database() {
378 let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
379 let payload = json!({});
380 let error = catalog
381 .try_schedule(CatalogJobScheduleSpec {
382 name: "jobs.test.schedule",
383 job_type: "jobs.test",
384 cron_expr: "0 * * * * *",
385 payload_template: &payload,
386 is_active: true,
387 organization_id: None,
388 max_jitter_seconds: 86_401,
389 next_fire_at: None,
390 })
391 .expect_err("excessive jitter");
392 assert!(matches!(
393 error,
394 CatalogError::InvalidScheduleSpec {
395 field: "max_jitter_seconds",
396 ..
397 }
398 ));
399 }
400
401 #[test]
402 fn schedule_sync_scope_uses_registered_schedule_names() {
403 let payload = json!({});
404 let catalog = JobCatalog::new()
405 .job("jobs.test", StaticHandler("jobs.test"))
406 .schedule(CatalogJobScheduleSpec {
407 name: "jobs.test.hourly",
408 job_type: "jobs.test",
409 cron_expr: "0 0 * * * *",
410 payload_template: &payload,
411 is_active: true,
412 organization_id: None,
413 max_jitter_seconds: 0,
414 next_fire_at: None,
415 })
416 .schedule(CatalogJobScheduleSpec {
417 name: "jobs.test.daily",
418 job_type: "jobs.test",
419 cron_expr: "0 0 0 * * *",
420 payload_template: &payload,
421 is_active: true,
422 organization_id: None,
423 max_jitter_seconds: 0,
424 next_fire_at: None,
425 });
426
427 let scope = catalog
428 .schedule_sync_scope()
429 .expect("registered schedules should build scope");
430 assert_eq!(
431 scope,
432 JobCatalogScheduleSyncScope::schedule_names(["jobs.test.daily", "jobs.test.hourly"])
433 .expect("expected scope")
434 );
435 }
436
437 #[test]
438 fn schedule_sync_scope_rejects_empty_registered_schedule_set() {
439 let error = JobCatalog::new()
440 .job("jobs.test", StaticHandler("jobs.test"))
441 .schedule_sync_scope()
442 .expect_err("empty schedule scope");
443 assert!(matches!(error, CatalogError::InvalidExactScheduleSyncScope));
444 }
445
446 #[test]
447 fn definition_overrides_apply_after_job_registration() {
448 let catalog = JobCatalog::new()
449 .job("jobs.test", StaticHandler("jobs.test"))
450 .definition_overrides(
451 "jobs.test",
452 JobCatalogDefinitionOverrides::new().enabled(true),
453 )
454 .defaults(JobCatalogDefaults::new().enabled(false));
455 let payload = json!({});
456 let enqueue = catalog
457 .job_enqueue(&CatalogJobEnqueueInput {
458 job_type: "jobs.test",
459 organization_id: None,
460 payload: &payload,
461 priority: None,
462 max_attempts: None,
463 timeout_seconds: None,
464 next_run_at: None,
465 idempotency_key: None,
466 stage: None,
467 })
468 .expect("post-registration override should apply");
469 assert_eq!(enqueue.job_type.as_str(), "jobs.test");
470 }
471
472 #[test]
473 fn definition_overrides_reject_unknown_job_type() {
474 let error = JobCatalog::new()
475 .try_definition_overrides(
476 "jobs.missing",
477 JobCatalogDefinitionOverrides::new().enabled(true),
478 )
479 .expect_err("unknown job type");
480 assert!(matches!(error, CatalogError::UnknownJobType { .. }));
481 }
482
483 #[test]
484 fn definition_overrides_reject_invalid_job_type() {
485 let error = JobCatalog::new()
486 .job("jobs.test", StaticHandler("jobs.test"))
487 .try_definition_overrides(" ", JobCatalogDefinitionOverrides::new().enabled(true))
488 .expect_err("invalid job type");
489 assert!(matches!(error, CatalogError::InvalidJobType { .. }));
490 }
491
492 #[test]
493 fn try_definition_overrides_reject_invalid_values() {
494 let error = JobCatalog::new()
495 .job("jobs.test", StaticHandler("jobs.test"))
496 .try_definition_overrides(
497 "jobs.test",
498 JobCatalogDefinitionOverrides::new().max_attempts(0),
499 )
500 .expect_err("invalid override");
501 assert!(matches!(
502 error,
503 CatalogError::InvalidJobDefinitionValue {
504 job_type,
505 field: "max_attempts",
506 } if job_type == "jobs.test"
507 ));
508 }
509
510 #[test]
511 fn workflow_dag_propagates_blank_step_key_error() {
512 let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
513 let error = catalog
514 .workflow_dag("workflow.test", &json!({}))
515 .job(" ", "jobs.test", &json!({}))
516 .expect_err("blank step key");
517 assert!(matches!(
518 error,
519 CatalogError::WorkflowBuild(WorkflowBuildError::BlankStepKey { .. })
520 ));
521 }
522
523 #[test]
524 fn workflow_dag_propagates_unknown_dependency_error() {
525 let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
526 let error = catalog
527 .workflow_dag("workflow.test", &json!({}))
528 .job("first", "jobs.test", &json!({}))
529 .expect("first step")
530 .after_success("missing", ["first"])
531 .expect_err("unknown dependency target");
532 assert!(matches!(
533 error,
534 CatalogError::WorkflowBuild(WorkflowBuildError::UnknownStepKey { .. })
535 ));
536 }
537
538 #[test]
539 fn workflow_dag_propagates_empty_workflow_error() {
540 let catalog = JobCatalog::new();
541 let error = catalog
542 .workflow_dag("workflow.test", &json!({}))
543 .try_build()
544 .expect_err("empty workflow");
545 assert!(matches!(
546 error,
547 CatalogError::WorkflowBuild(WorkflowBuildError::EmptySteps)
548 ));
549 }
550}