WP-11: contract and failing tests for package upgrades
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:24:40 +02:00
parent b6ddb8889d
commit 1f56f015a2
18 changed files with 524 additions and 2 deletions

View File

@ -4,12 +4,14 @@ pub mod inventory_service;
pub mod jobs;
pub mod scheduler;
pub mod settings_service;
pub mod upgrade_service;
pub mod user_service;
pub use auth_service::AuthService;
pub use inventory_service::{InventoryService, PackageRefreshJob};
pub use jobs::{JobHandler, JobLog, JobRunner};
pub use settings_service::SettingsService;
pub use upgrade_service::{PackageUpgradeJob, UpgradeParams};
pub use user_service::UserService;
#[cfg(test)]

View File

@ -368,3 +368,34 @@ impl InventoryRepository for MemInventory {
Ok(self.0.lock().unwrap().clone())
}
}
use domain::ports::{HostUpdater, LineSink};
/// Records the requested packages and emits a few lines.
#[derive(Default)]
pub struct FakeUpdater {
pub calls: Mutex<Vec<Vec<String>>>,
pub fail: bool,
}
#[async_trait]
impl HostUpdater for FakeUpdater {
async fn upgrade(&self, packages: &[String], out: &dyn LineSink) -> Result<(), DomainError> {
self.calls.lock().unwrap().push(packages.to_vec());
out.line("Reading package lists...");
out.line(&format!(
"Upgrading {} package(s)",
if packages.is_empty() {
"all".to_string()
} else {
packages.len().to_string()
}
));
if self.fail {
return Err(DomainError::Unavailable(
"apt-get exited with status 100".into(),
));
}
Ok(())
}
}

View File

@ -3,4 +3,5 @@ mod inventory_tests;
mod jobs_tests;
mod scheduler_tests;
mod settings_tests;
mod upgrade_tests;
mod user_service_tests;

View File

@ -0,0 +1,105 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::DomainError;
use crate::jobs::{JobHandler, JobLog};
use crate::test_fakes::{FakeInspector, FakeUpdater, MemInventory};
use crate::{InventoryService, PackageUpgradeJob, UpgradeParams};
#[derive(Default)]
struct VecLog(Mutex<Vec<String>>);
#[async_trait]
impl JobLog for VecLog {
async fn line(&self, text: &str) {
self.0.lock().unwrap().push(text.into());
}
}
#[test]
fn params_parse_and_validate() {
assert_eq!(
UpgradeParams::from_json(None).unwrap(),
UpgradeParams::default()
);
assert_eq!(
UpgradeParams::from_json(Some("")).unwrap(),
UpgradeParams::default()
);
let p = UpgradeParams::from_json(Some(r#"{"packages":["openssl","libssl3"]}"#)).unwrap();
assert_eq!(p.packages, vec!["openssl", "libssl3"]);
assert!(matches!(
UpgradeParams::from_json(Some("{not json")).unwrap_err(),
DomainError::Validation(_)
));
let bad = UpgradeParams {
packages: vec!["rm -rf /".into()],
};
assert!(matches!(
bad.validate().unwrap_err(),
DomainError::Validation(_)
));
assert!(matches!(
UpgradeParams::from_json(Some(r#"{"packages":["../x"]}"#)).unwrap_err(),
DomainError::Validation(_)
));
}
fn job(fail: bool) -> (Arc<FakeUpdater>, Arc<MemInventory>, PackageUpgradeJob) {
let updater = Arc::new(FakeUpdater {
fail,
..Default::default()
});
let repo = Arc::new(MemInventory::default());
let inventory = Arc::new(InventoryService::new(
Arc::new(FakeInspector { fail: false }),
repo.clone(),
));
(
updater.clone(),
repo,
PackageUpgradeJob { updater, inventory },
)
}
#[tokio::test]
async fn upgrades_selected_packages_streams_output_and_refreshes_inventory() {
let (updater, repo, job) = job(false);
let log = VecLog::default();
job.run(Some(r#"{"packages":["openssl"]}"#.into()), &log)
.await
.unwrap();
assert_eq!(
updater.calls.lock().unwrap()[0],
vec!["openssl".to_string()]
);
let lines = log.0.lock().unwrap().join("\n");
assert!(lines.contains("Reading package lists"), "{lines}");
assert!(lines.contains("Upgrading 1 package"), "{lines}");
assert!(lines.contains("reboot required"), "{lines}");
assert!(
repo.0.lock().unwrap().is_some(),
"inventory refreshed after upgrade"
);
}
#[tokio::test]
async fn empty_params_upgrade_everything() {
let (updater, _, job) = job(false);
job.run(None, &VecLog::default()).await.unwrap();
assert!(updater.calls.lock().unwrap()[0].is_empty());
}
#[tokio::test]
async fn updater_failure_fails_the_job_and_keeps_output() {
let (_, _, job) = job(true);
let log = VecLog::default();
let err = job.run(None, &log).await.unwrap_err();
assert!(err.contains("status 100"));
assert!(log
.0
.lock()
.unwrap()
.iter()
.any(|l| l.contains("Reading package lists")));
}

View File

@ -0,0 +1,48 @@
//! Package upgrade job: runs the host updater, streams its output into the job log,
//! then refreshes the inventory.
use std::sync::Arc;
use async_trait::async_trait;
use domain::host::validate_package_name;
use domain::ports::HostUpdater;
use domain::DomainError;
use serde::{Deserialize, Serialize};
use crate::jobs::{JobHandler, JobLog};
use crate::InventoryService;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeParams {
/// Empty = upgrade all packages.
#[serde(default)]
pub packages: Vec<String>,
}
impl UpgradeParams {
pub fn validate(&self) -> Result<(), DomainError> {
self.packages
.iter()
.try_for_each(|p| validate_package_name(p))
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_json(s: Option<&str>) -> Result<Self, DomainError> {
let _ = s;
todo!()
}
}
pub struct PackageUpgradeJob {
pub updater: Arc<dyn HostUpdater>,
pub inventory: Arc<InventoryService>,
}
#[async_trait]
impl JobHandler for PackageUpgradeJob {
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
todo!()
}
}