shepherd-launcher/crates/shepherdd/tests/integration.rs
2025-12-26 15:35:27 -05:00

321 lines
10 KiB
Rust

//! Integration tests for shepherdd
//!
//! These tests verify the end-to-end behavior of the daemon.
use chrono::Local;
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, Policy};
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision};
use shepherd_host_api::{HostCapabilities, MockHost};
use shepherd_store::{SqliteStore, Store};
use shepherd_util::{EntryId, MonotonicInstant};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
fn make_test_policy() -> Policy {
Policy {
daemon: Default::default(),
entries: vec![
Entry {
id: EntryId::new("test-game"),
label: "Test Game".into(),
icon_ref: None,
kind: EntryKind::Process {
argv: vec!["sleep".into(), "999".into()],
env: HashMap::new(),
cwd: None,
},
availability: AvailabilityPolicy {
windows: vec![],
always: true,
},
limits: LimitsPolicy {
max_run: Duration::from_secs(10), // Short for testing
daily_quota: None,
cooldown: None,
},
warnings: vec![
WarningThreshold {
seconds_before: 5,
severity: WarningSeverity::Warn,
message_template: Some("5 seconds left".into()),
},
WarningThreshold {
seconds_before: 2,
severity: WarningSeverity::Critical,
message_template: Some("2 seconds left!".into()),
},
],
disabled: false,
disabled_reason: None,
},
],
default_warnings: vec![],
default_max_run: Duration::from_secs(3600),
}
}
#[test]
fn test_policy_loading() {
let policy = make_test_policy();
assert_eq!(policy.entries.len(), 1);
assert_eq!(policy.entries[0].id.as_str(), "test-game");
}
#[test]
fn test_entry_listing() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let engine = CoreEngine::new(policy, store, caps);
let entries = engine.list_entries(Local::now());
assert_eq!(entries.len(), 1);
assert!(entries[0].enabled);
assert_eq!(entries[0].entry_id.as_str(), "test-game");
assert!(entries[0].max_run_if_started_now.is_some());
}
#[test]
fn test_launch_approval() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let decision = engine.request_launch(&entry_id, Local::now());
assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Duration::from_secs(10)));
}
#[test]
fn test_session_lifecycle() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let mut engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let now = Local::now();
let now_mono = MonotonicInstant::now();
// Launch
let plan = match engine.request_launch(&entry_id, now) {
LaunchDecision::Approved(p) => p,
LaunchDecision::Denied { .. } => panic!("Launch should be approved"),
};
// Start session
let event = engine.start_session(plan, now, now_mono);
assert!(matches!(event, CoreEvent::SessionStarted { .. }));
// Verify session is active
assert!(engine.has_active_session());
// Second launch should be denied
let decision = engine.request_launch(&entry_id, now);
assert!(matches!(decision, LaunchDecision::Denied { .. }));
}
#[test]
fn test_warning_emission() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let mut engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let now = Local::now();
let now_mono = MonotonicInstant::now();
// Start session
let plan = match engine.request_launch(&entry_id, now) {
LaunchDecision::Approved(p) => p,
_ => panic!(),
};
engine.start_session(plan, now, now_mono);
// No warnings at start
let events = engine.tick(now_mono);
assert!(events.is_empty());
// At 6 seconds (4 seconds remaining), 5-second warning should fire
let at_6s = now_mono + Duration::from_secs(6);
let events = engine.tick(at_6s);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], CoreEvent::Warning { threshold_seconds: 5, .. }));
// At 9 seconds (1 second remaining), 2-second warning should fire
let at_9s = now_mono + Duration::from_secs(9);
let events = engine.tick(at_9s);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], CoreEvent::Warning { threshold_seconds: 2, .. }));
// Warnings shouldn't repeat
let events = engine.tick(at_9s);
assert!(events.is_empty());
}
#[test]
fn test_session_expiry() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let mut engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let now = Local::now();
let now_mono = MonotonicInstant::now();
// Start session
let plan = match engine.request_launch(&entry_id, now) {
LaunchDecision::Approved(p) => p,
_ => panic!(),
};
engine.start_session(plan, now, now_mono);
// At 11 seconds, session should be expired
let at_11s = now_mono + Duration::from_secs(11);
let events = engine.tick(at_11s);
// Should have both remaining warnings + expiry
let has_expiry = events.iter().any(|e| matches!(e, CoreEvent::ExpireDue { .. }));
assert!(has_expiry, "Expected ExpireDue event");
}
#[test]
fn test_usage_accounting() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let store_check = store.clone();
let caps = HostCapabilities::minimal();
let mut engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let now = Local::now();
let now_mono = MonotonicInstant::now();
// Start session
let plan = match engine.request_launch(&entry_id, now) {
LaunchDecision::Approved(p) => p,
_ => panic!(),
};
engine.start_session(plan, now, now_mono);
// Simulate 5 seconds passing
let later_mono = now_mono + Duration::from_secs(5);
let later = now + chrono::Duration::seconds(5);
// Session exits
engine.notify_session_exited(Some(0), later_mono, later);
// Check usage was recorded
let usage = store_check.get_usage(&entry_id, now.date_naive()).unwrap();
assert!(usage >= Duration::from_secs(4) && usage <= Duration::from_secs(6));
}
#[tokio::test]
async fn test_mock_host_integration() {
use shepherd_host_api::{HostAdapter, SpawnOptions};
use shepherd_util::SessionId;
let host = MockHost::new();
let _rx = host.subscribe();
let session_id = SessionId::new();
let entry = EntryKind::Process {
argv: vec!["test".into()],
env: HashMap::new(),
cwd: None,
};
// Spawn
let handle = host
.spawn(session_id.clone(), &entry, SpawnOptions::default())
.await
.unwrap();
// Verify running
assert_eq!(host.running_sessions().len(), 1);
// Stop
host.stop(
&handle,
shepherd_host_api::StopMode::Graceful {
timeout: Duration::from_secs(1),
},
)
.await
.unwrap();
}
#[test]
fn test_config_parsing() {
use shepherd_config::parse_config;
let config = r#"
config_version = 1
[[entries]]
id = "scummvm"
label = "ScummVM"
kind = { type = "process", argv = ["scummvm", "-f"] }
[entries.availability]
[[entries.availability.windows]]
days = "weekdays"
start = "14:00"
end = "18:00"
[entries.limits]
max_run_seconds = 3600
daily_quota_seconds = 7200
cooldown_seconds = 300
[[entries.warnings]]
seconds_before = 300
severity = "info"
message = "5 minutes remaining"
"#;
let policy = parse_config(config).unwrap();
assert_eq!(policy.entries.len(), 1);
assert_eq!(policy.entries[0].id.as_str(), "scummvm");
assert_eq!(policy.entries[0].limits.max_run, Duration::from_secs(3600));
assert_eq!(policy.entries[0].limits.daily_quota, Some(Duration::from_secs(7200)));
assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300)));
assert_eq!(policy.entries[0].warnings.len(), 1);
}
#[test]
fn test_session_extension() {
let policy = make_test_policy();
let store = Arc::new(SqliteStore::in_memory().unwrap());
let caps = HostCapabilities::minimal();
let mut engine = CoreEngine::new(policy, store, caps);
let entry_id = EntryId::new("test-game");
let now = Local::now();
let now_mono = MonotonicInstant::now();
// Start session
let plan = match engine.request_launch(&entry_id, now) {
LaunchDecision::Approved(p) => p,
_ => panic!(),
};
engine.start_session(plan, now, now_mono);
// Get original deadline
let original_deadline = engine.current_session().unwrap().deadline;
// Extend by 5 minutes
let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now);
assert!(new_deadline.is_some());
let new_deadline = new_deadline.unwrap();
let extension = new_deadline.signed_duration_since(original_deadline);
assert!(extension.num_seconds() >= 299 && extension.num_seconds() <= 301);
}