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

@ -16,6 +16,10 @@ pub enum DomainError {
LastAdmin,
#[error("validation failed: {0}")]
Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("external service unavailable: {0}")]
Unavailable(String),
#[error("storage error: {0}")]
Storage(String),
}

View File

@ -0,0 +1,78 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobKind {
PackageRefresh,
PackageUpgrade,
VulnerabilityScan,
Backup,
}
impl JobKind {
pub const ALL: [JobKind; 4] = [
JobKind::PackageRefresh,
JobKind::PackageUpgrade,
JobKind::VulnerabilityScan,
JobKind::Backup,
];
pub fn as_str(self) -> &'static str {
match self {
JobKind::PackageRefresh => "package_refresh",
JobKind::PackageUpgrade => "package_upgrade",
JobKind::VulnerabilityScan => "vulnerability_scan",
JobKind::Backup => "backup",
}
}
pub fn parse(s: &str) -> Option<JobKind> {
JobKind::ALL.into_iter().find(|k| k.as_str() == s)
}
/// Kinds that run on a schedule; the others are triggered manually or by their owner.
pub fn default_schedule(self) -> Option<&'static str> {
match self {
JobKind::PackageRefresh => Some("0 0 * * * *"),
JobKind::VulnerabilityScan => Some("0 0 3 * * *"),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Running,
Success,
Failed,
}
impl JobStatus {
pub fn as_str(self) -> &'static str {
match self {
JobStatus::Running => "running",
JobStatus::Success => "success",
JobStatus::Failed => "failed",
}
}
pub fn parse(s: &str) -> Option<JobStatus> {
[JobStatus::Running, JobStatus::Success, JobStatus::Failed]
.into_iter()
.find(|k| k.as_str() == s)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JobRun {
pub id: Uuid,
pub kind: JobKind,
pub params: Option<String>,
pub status: JobStatus,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub log: String,
pub triggered_by: String,
}

View File

@ -2,7 +2,9 @@
//! layer depends on. No I/O here.
pub mod auth;
pub mod error;
pub mod jobs;
pub mod ports;
pub mod settings;
pub mod user;
pub use error::DomainError;

View File

@ -3,6 +3,8 @@ use async_trait::async_trait;
use uuid::Uuid;
use crate::auth::{AccessClaims, AuthEvent, RefreshToken};
use crate::jobs::{JobKind, JobRun, JobStatus};
use crate::settings::SmtpSettings;
use crate::user::{User, UserUpdate};
use crate::DomainError;
@ -40,3 +42,37 @@ pub trait AccessTokenIssuer: Send + Sync {
fn issue(&self, user: &User) -> Result<String, DomainError>;
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError>;
}
#[async_trait]
pub trait SettingsRepository: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<String>, DomainError>;
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError>;
}
/// Symmetric encryption for secrets at rest.
pub trait Cipher: Send + Sync {
fn encrypt(&self, plain: &str) -> Result<String, DomainError>;
fn decrypt(&self, cipher_text: &str) -> Result<String, DomainError>;
}
#[async_trait]
pub trait Mailer: Send + Sync {
async fn send(
&self,
smtp: &SmtpSettings,
to: &[String],
subject: &str,
body: &str,
) -> Result<(), DomainError>;
}
#[async_trait]
pub trait JobRunRepository: Send + Sync {
async fn insert(&self, run: &JobRun) -> Result<(), DomainError>;
async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError>;
async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError>;
async fn get(&self, id: Uuid) -> Result<Option<JobRun>, DomainError>;
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError>;
async fn find_running(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError>;
async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError>;
}

View File

@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SmtpSecurity {
None,
StartTls,
Tls,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmtpSettings {
pub host: String,
pub port: u16,
pub security: SmtpSecurity,
pub username: String,
pub password: String,
pub from: String,
/// Default recipients for notifications.
pub notify_to: Vec<String>,
}
impl SmtpSettings {
pub fn validate(&self) -> Result<(), crate::DomainError> {
let err = |m: &str| Err(crate::DomainError::Validation(m.into()));
if self.host.trim().is_empty() {
return err("smtp host is required");
}
if self.port == 0 {
return err("smtp port is required");
}
if !self.from.contains('@') {
return err("from address is invalid");
}
if self.notify_to.iter().any(|a| !a.contains('@')) {
return err("notification recipient is invalid");
}
Ok(())
}
}
/// Settings keys. Secrets are encrypted at rest by the application layer.
pub const KEY_SMTP: &str = "smtp";
pub const SECRET_KEYS: &[&str] = &[KEY_SMTP];