WP-02: contracts and failing tests for settings, mail, jobs and scheduler
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:08:23 +02:00
parent 12269051c4
commit 6113af0a19
21 changed files with 1115 additions and 0 deletions

View File

@ -0,0 +1,104 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::jobs::{JobKind, JobStatus};
use domain::DomainError;
use uuid::Uuid;
use crate::jobs::{JobHandler, JobLog, JobRunner};
use crate::test_fakes::MemJobRuns;
struct Echo;
#[async_trait]
impl JobHandler for Echo {
async fn run(&self, params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
log.line("starting").await;
match params.as_deref() {
Some("fail") => Err("boom".into()),
Some("slow") => {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
Ok(())
}
_ => {
log.line("done").await;
Ok(())
}
}
}
}
fn runner() -> (Arc<MemJobRuns>, JobRunner) {
let runs = Arc::new(MemJobRuns::default());
(
runs.clone(),
JobRunner::new(runs).register(JobKind::PackageRefresh, Arc::new(Echo)),
)
}
#[tokio::test]
async fn run_and_wait_persists_log_and_success() {
let (_, r) = runner();
let run = r
.run_and_wait(JobKind::PackageRefresh, None, "test")
.await
.unwrap();
assert_eq!(run.status, JobStatus::Success);
assert_eq!(run.log, "starting\ndone\n");
assert!(run.finished_at.is_some());
assert_eq!(run.triggered_by, "test");
}
#[tokio::test]
async fn handler_error_marks_run_failed_and_logs_the_error() {
let (_, r) = runner();
let run = r
.run_and_wait(JobKind::PackageRefresh, Some("fail".into()), "test")
.await
.unwrap();
assert_eq!(run.status, JobStatus::Failed);
assert!(run.log.contains("boom"));
}
#[tokio::test]
async fn start_returns_immediately_and_rejects_concurrent_runs_of_same_kind() {
let (_, r) = runner();
let run = r
.start(JobKind::PackageRefresh, Some("slow".into()), "test")
.await
.unwrap();
assert_eq!(run.status, JobStatus::Running);
let err = r
.start(JobKind::PackageRefresh, None, "test")
.await
.unwrap_err();
assert!(matches!(err, DomainError::Conflict(_)));
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert_eq!(r.get(run.id).await.unwrap().status, JobStatus::Success);
}
#[tokio::test]
async fn unknown_kind_and_unknown_id_are_errors() {
let (_, r) = runner();
assert!(matches!(
r.start(JobKind::Backup, None, "test").await.unwrap_err(),
DomainError::NotFound
));
assert_eq!(
r.get(Uuid::new_v4()).await.unwrap_err(),
DomainError::NotFound
);
assert_eq!(r.kinds(), vec![JobKind::PackageRefresh]);
}
#[tokio::test]
async fn list_returns_newest_first_with_limit() {
let (_, r) = runner();
for _ in 0..3 {
r.run_and_wait(JobKind::PackageRefresh, None, "test")
.await
.unwrap();
}
let list = r.list(2).await.unwrap();
assert_eq!(list.len(), 2);
assert!(list[0].started_at >= list[1].started_at);
}

View File

@ -1,2 +1,5 @@
mod auth_service_tests;
mod jobs_tests;
mod scheduler_tests;
mod settings_tests;
mod user_service_tests;

View File

@ -0,0 +1,33 @@
use chrono::{Duration, TimeZone, Utc};
use crate::scheduler::{is_due, next_fire, validate_cron};
#[test]
fn validates_six_field_cron() {
assert!(validate_cron("0 0 * * * *").is_ok());
assert!(validate_cron("0 */15 * * * *").is_ok());
assert!(validate_cron("garbage").is_err());
assert!(validate_cron("").is_err());
}
#[test]
fn next_fire_is_strictly_after() {
let t = Utc.with_ymd_and_hms(2026, 9, 2, 10, 0, 0).unwrap();
assert_eq!(
next_fire("0 0 * * * *", t).unwrap(),
Utc.with_ymd_and_hms(2026, 9, 2, 11, 0, 0).unwrap()
);
assert_eq!(next_fire("bad", t), None);
}
#[test]
fn due_when_a_fire_time_lies_between_last_run_and_now() {
let now = Utc.with_ymd_and_hms(2026, 9, 2, 10, 0, 30).unwrap();
let hourly = "0 0 * * * *";
assert!(is_due(hourly, Some(now - Duration::minutes(5)), now, 300));
assert!(!is_due(hourly, Some(now - Duration::seconds(10)), now, 300));
// never ran: due only if a fire time is within the grace window
assert!(is_due(hourly, None, now, 300));
assert!(!is_due(hourly, None, now + Duration::minutes(10), 300));
assert!(!is_due("bad", None, now, 300));
}

View File

@ -0,0 +1,98 @@
use std::sync::Arc;
use domain::jobs::JobKind;
use domain::DomainError;
use crate::test_fakes::{smtp, FakeCipher, MemMailer, MemSettings};
use crate::SettingsService;
struct F {
repo: Arc<MemSettings>,
mailer: Arc<MemMailer>,
svc: SettingsService,
}
fn f() -> F {
let repo = Arc::new(MemSettings::default());
let mailer = Arc::new(MemMailer::default());
let svc = SettingsService::new(repo.clone(), Arc::new(FakeCipher), mailer.clone());
F { repo, mailer, svc }
}
#[tokio::test]
async fn smtp_is_stored_encrypted_and_read_back() {
let f = f();
assert_eq!(f.svc.smtp().await.unwrap(), None);
f.svc.set_smtp(smtp()).await.unwrap();
let stored = f.repo.0.lock().unwrap().get("smtp").cloned().unwrap();
assert!(stored.starts_with("enc:"));
assert!(!stored.contains("s3cret"));
assert_eq!(f.svc.smtp().await.unwrap(), Some(smtp()));
}
#[tokio::test]
async fn smtp_is_validated() {
let f = f();
let mut bad = smtp();
bad.host = " ".into();
assert!(matches!(
f.svc.set_smtp(bad).await.unwrap_err(),
DomainError::Validation(_)
));
}
#[tokio::test]
async fn send_mail_uses_notify_recipients_by_default() {
let f = f();
assert!(matches!(
f.svc.send_mail(None, "s", "b").await.unwrap_err(),
DomainError::Validation(_)
));
f.svc.set_smtp(smtp()).await.unwrap();
f.svc.send_mail(None, "Hello", "World").await.unwrap();
f.svc
.send_mail(Some(vec!["me@example.com".into()]), "Test", "x")
.await
.unwrap();
let sent = f.mailer.0.lock().unwrap();
assert_eq!(sent[0].0, vec!["ops@example.com".to_string()]);
assert_eq!(sent[0].1, "Hello");
assert_eq!(sent[1].0, vec!["me@example.com".to_string()]);
}
#[tokio::test]
async fn schedules_have_defaults_and_can_be_changed_or_disabled() {
let f = f();
assert_eq!(
f.svc
.schedule(JobKind::PackageRefresh)
.await
.unwrap()
.as_deref(),
Some("0 0 * * * *")
);
assert_eq!(f.svc.schedule(JobKind::Backup).await.unwrap(), None);
f.svc
.set_schedule(JobKind::PackageRefresh, Some("0 */30 * * * *".into()))
.await
.unwrap();
assert_eq!(
f.svc
.schedule(JobKind::PackageRefresh)
.await
.unwrap()
.as_deref(),
Some("0 */30 * * * *")
);
f.svc
.set_schedule(JobKind::PackageRefresh, None)
.await
.unwrap();
assert_eq!(f.svc.schedule(JobKind::PackageRefresh).await.unwrap(), None);
let err = f
.svc
.set_schedule(JobKind::PackageRefresh, Some("not a cron".into()))
.await
.unwrap_err();
assert!(matches!(err, DomainError::Validation(_)));
}