WP-10: contract and failing tests for OS and package inventory
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
40
backend/crates/application/src/inventory_service.rs
Normal file
40
backend/crates/application/src/inventory_service.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::host::Inventory;
|
||||
use domain::ports::{HostInspector, InventoryRepository};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::jobs::{JobHandler, JobLog};
|
||||
|
||||
pub struct InventoryService {
|
||||
inspector: Arc<dyn HostInspector>,
|
||||
repo: Arc<dyn InventoryRepository>,
|
||||
}
|
||||
|
||||
impl InventoryService {
|
||||
pub fn new(inspector: Arc<dyn HostInspector>, repo: Arc<dyn InventoryRepository>) -> Self {
|
||||
let _ = (&inspector, &repo);
|
||||
Self { inspector, repo }
|
||||
}
|
||||
|
||||
/// Query the host and persist a new snapshot.
|
||||
pub async fn refresh(&self) -> Result<Inventory, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Last persisted snapshot.
|
||||
pub async fn current(&self) -> Result<Option<Inventory>, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Job handler for `JobKind::PackageRefresh`.
|
||||
pub struct PackageRefreshJob(pub Arc<InventoryService>);
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for PackageRefreshJob {
|
||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
//! Application layer: use cases orchestrating the domain through its ports.
|
||||
pub mod auth_service;
|
||||
pub mod inventory_service;
|
||||
pub mod jobs;
|
||||
pub mod scheduler;
|
||||
pub mod settings_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 user_service::UserService;
|
||||
|
||||
@ -305,3 +305,66 @@ pub fn smtp() -> SmtpSettings {
|
||||
notify_to: vec!["ops@example.com".into()],
|
||||
}
|
||||
}
|
||||
|
||||
use domain::host::{Inventory, OsInfo, Package, PackageSource};
|
||||
use domain::ports::{HostInspector, InventoryRepository};
|
||||
|
||||
pub struct FakeInspector {
|
||||
pub fail: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HostInspector for FakeInspector {
|
||||
async fn os_info(&self) -> Result<OsInfo, DomainError> {
|
||||
if self.fail {
|
||||
return Err(DomainError::Unavailable("host down".into()));
|
||||
}
|
||||
Ok(OsInfo {
|
||||
hostname: "srv".into(),
|
||||
name: "Debian GNU/Linux 12 (bookworm)".into(),
|
||||
version: "12".into(),
|
||||
kernel: "6.1.0-42-amd64".into(),
|
||||
uptime_secs: 3600,
|
||||
reboot_required: true,
|
||||
})
|
||||
}
|
||||
async fn packages(&self) -> Result<Vec<Package>, DomainError> {
|
||||
Ok(vec![
|
||||
Package {
|
||||
name: "bash".into(),
|
||||
source: PackageSource::Apt,
|
||||
installed: "5.2".into(),
|
||||
candidate: None,
|
||||
is_security: false,
|
||||
},
|
||||
Package {
|
||||
name: "openssl".into(),
|
||||
source: PackageSource::Apt,
|
||||
installed: "3.0.1".into(),
|
||||
candidate: Some("3.0.2".into()),
|
||||
is_security: true,
|
||||
},
|
||||
Package {
|
||||
name: "microk8s".into(),
|
||||
source: PackageSource::Snap,
|
||||
installed: "v1.32.13".into(),
|
||||
candidate: None,
|
||||
is_security: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemInventory(pub Mutex<Option<Inventory>>);
|
||||
|
||||
#[async_trait]
|
||||
impl InventoryRepository for MemInventory {
|
||||
async fn save(&self, inventory: &Inventory) -> Result<(), DomainError> {
|
||||
*self.0.lock().unwrap() = Some(inventory.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn load(&self) -> Result<Option<Inventory>, DomainError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
}
|
||||
|
||||
67
backend/crates/application/src/tests/inventory_tests.rs
Normal file
67
backend/crates/application/src/tests/inventory_tests.rs
Normal file
@ -0,0 +1,67 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::jobs::{JobHandler, JobLog};
|
||||
use crate::test_fakes::{FakeInspector, MemInventory};
|
||||
use crate::{InventoryService, PackageRefreshJob};
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_persists_snapshot_and_current_returns_it() {
|
||||
let repo = Arc::new(MemInventory::default());
|
||||
let svc = InventoryService::new(Arc::new(FakeInspector { fail: false }), repo.clone());
|
||||
assert_eq!(svc.current().await.unwrap(), None);
|
||||
let inv = svc.refresh().await.unwrap();
|
||||
assert_eq!(inv.os.hostname, "srv");
|
||||
assert_eq!(inv.packages.len(), 3);
|
||||
assert_eq!(inv.upgradable(), 1);
|
||||
assert_eq!(inv.security_upgrades(), 1);
|
||||
assert_eq!(svc.current().await.unwrap(), Some(inv));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_failure_keeps_previous_snapshot() {
|
||||
let repo = Arc::new(MemInventory::default());
|
||||
let ok = InventoryService::new(Arc::new(FakeInspector { fail: false }), repo.clone());
|
||||
let before = ok.refresh().await.unwrap();
|
||||
let failing = InventoryService::new(Arc::new(FakeInspector { fail: true }), repo.clone());
|
||||
assert!(matches!(
|
||||
failing.refresh().await.unwrap_err(),
|
||||
DomainError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(failing.current().await.unwrap(), Some(before));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_handler_logs_summary_and_maps_errors() {
|
||||
let repo = Arc::new(MemInventory::default());
|
||||
let job = PackageRefreshJob(Arc::new(InventoryService::new(
|
||||
Arc::new(FakeInspector { fail: false }),
|
||||
repo.clone(),
|
||||
)));
|
||||
let log = VecLog::default();
|
||||
job.run(None, &log).await.unwrap();
|
||||
let lines = log.0.lock().unwrap().join("\n");
|
||||
assert!(lines.contains("3 packages"), "{lines}");
|
||||
assert!(lines.contains("1 upgradable"), "{lines}");
|
||||
|
||||
let job = PackageRefreshJob(Arc::new(InventoryService::new(
|
||||
Arc::new(FakeInspector { fail: true }),
|
||||
repo,
|
||||
)));
|
||||
assert!(job
|
||||
.run(None, &VecLog::default())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("host down"));
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
mod auth_service_tests;
|
||||
mod inventory_tests;
|
||||
mod jobs_tests;
|
||||
mod scheduler_tests;
|
||||
mod settings_tests;
|
||||
|
||||
Reference in New Issue
Block a user