WP-11: contract and failing tests for package upgrades
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -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)]
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,4 +3,5 @@ mod inventory_tests;
|
||||
mod jobs_tests;
|
||||
mod scheduler_tests;
|
||||
mod settings_tests;
|
||||
mod upgrade_tests;
|
||||
mod user_service_tests;
|
||||
|
||||
105
backend/crates/application/src/tests/upgrade_tests.rs
Normal file
105
backend/crates/application/src/tests/upgrade_tests.rs
Normal 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")));
|
||||
}
|
||||
48
backend/crates/application/src/upgrade_service.rs
Normal file
48
backend/crates/application/src/upgrade_service.rs
Normal 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!()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user