WP-10: contract and failing tests for OS and package inventory
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:19:25 +02:00
parent b6c3ddf040
commit 7b6d242988
21 changed files with 698 additions and 0 deletions

View 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!()
}
}

View File

@ -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;

View File

@ -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())
}
}

View 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"));
}

View File

@ -1,4 +1,5 @@
mod auth_service_tests;
mod inventory_tests;
mod jobs_tests;
mod scheduler_tests;
mod settings_tests;