From f1e2c727aa9f9dd7ba794b590ae63ac27f5d94a7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 15 Mar 2026 23:56:52 +0100 Subject: [PATCH] feat(tests): add unit tests for auto-scheduler functionality --- k-tv-backend/api/src/scheduler.rs | 208 ++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/k-tv-backend/api/src/scheduler.rs b/k-tv-backend/api/src/scheduler.rs index 33b04d3..b25d440 100644 --- a/k-tv-backend/api/src/scheduler.rs +++ b/k-tv-backend/api/src/scheduler.rs @@ -74,3 +74,211 @@ async fn tick( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use chrono::{DateTime, Duration, Utc}; + use domain::{ + Channel, ChannelRepository, Collection, DomainResult, GeneratedSchedule, IProviderRegistry, + MediaFilter, MediaItem, MediaItemId, PlaybackRecord, ProviderCapabilities, ScheduleEngineService, + ScheduleRepository, SeriesSummary, StreamQuality, StreamingProtocol, + }; + use domain::value_objects::{ChannelId, ContentType, UserId}; + use uuid::Uuid; + + // ── Mocks ───────────────────────────────────────────────────────────────── + + struct MockChannelRepo { + channels: Vec, + } + + #[async_trait] + impl ChannelRepository for MockChannelRepo { + async fn find_by_id(&self, id: ChannelId) -> DomainResult> { + Ok(self.channels.iter().find(|c| c.id == id).cloned()) + } + async fn find_by_owner(&self, _owner_id: UserId) -> DomainResult> { + unimplemented!() + } + async fn find_all(&self) -> DomainResult> { + unimplemented!() + } + async fn find_auto_schedule_enabled(&self) -> DomainResult> { + Ok(self.channels.clone()) + } + async fn save(&self, _channel: &Channel) -> DomainResult<()> { + unimplemented!() + } + async fn delete(&self, _id: ChannelId) -> DomainResult<()> { + unimplemented!() + } + } + + struct MockScheduleRepo { + latest: Option, + saved: Arc>>, + } + + #[async_trait] + impl ScheduleRepository for MockScheduleRepo { + async fn find_active( + &self, + _channel_id: ChannelId, + _at: DateTime, + ) -> DomainResult> { + Ok(None) + } + async fn find_latest(&self, _channel_id: ChannelId) -> DomainResult> { + Ok(self.latest.clone()) + } + async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { + self.saved.lock().unwrap().push(schedule.clone()); + Ok(()) + } + async fn find_playback_history(&self, _channel_id: ChannelId) -> DomainResult> { + Ok(vec![]) + } + async fn save_playback_record(&self, _record: &PlaybackRecord) -> DomainResult<()> { + Ok(()) + } + } + + struct MockRegistry; + + #[async_trait] + impl IProviderRegistry for MockRegistry { + async fn fetch_items(&self, _provider_id: &str, _filter: &MediaFilter) -> DomainResult> { + Ok(vec![]) + } + async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult> { + Ok(None) + } + async fn get_stream_url(&self, _item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult { + unimplemented!() + } + fn provider_ids(&self) -> Vec { + vec![] + } + fn primary_id(&self) -> &str { + "" + } + fn capabilities(&self, _provider_id: &str) -> Option { + None + } + async fn list_collections(&self, _provider_id: &str) -> DomainResult> { + unimplemented!() + } + async fn list_series(&self, _provider_id: &str, _collection_id: Option<&str>) -> DomainResult> { + unimplemented!() + } + async fn list_genres(&self, _provider_id: &str, _content_type: Option<&ContentType>) -> DomainResult> { + unimplemented!() + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn make_channel() -> Channel { + let mut ch = Channel::new(Uuid::new_v4(), "Test", "UTC"); + ch.auto_schedule = true; + ch + } + + fn make_schedule(channel_id: ChannelId, valid_until: DateTime) -> GeneratedSchedule { + GeneratedSchedule { + id: Uuid::new_v4(), + channel_id, + valid_from: valid_until - Duration::hours(48), + valid_until, + generation: 1, + slots: vec![], + } + } + + fn make_engine( + channel_repo: Arc, + schedule_repo: Arc, + ) -> Arc { + Arc::new(ScheduleEngineService::new( + Arc::new(MockRegistry), + channel_repo, + schedule_repo, + )) + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn test_no_schedule_generates_from_now() { + let ch = make_channel(); + let saved = Arc::new(Mutex::new(vec![])); + let channel_repo: Arc = Arc::new(MockChannelRepo { channels: vec![ch] }); + let schedule_repo: Arc = + Arc::new(MockScheduleRepo { latest: None, saved: saved.clone() }); + let engine = make_engine(channel_repo.clone(), schedule_repo); + + tick(&engine, &channel_repo).await; + + let saved = saved.lock().unwrap(); + assert_eq!(saved.len(), 1); + let diff = (saved[0].valid_from - Utc::now()).num_seconds().abs(); + assert!(diff < 5, "valid_from should be ~now, diff={diff}"); + } + + #[tokio::test] + async fn test_fresh_schedule_skips() { + let ch = make_channel(); + let valid_until = Utc::now() + Duration::hours(25); + let schedule = make_schedule(ch.id, valid_until); + let saved = Arc::new(Mutex::new(vec![])); + let channel_repo: Arc = Arc::new(MockChannelRepo { channels: vec![ch] }); + let schedule_repo: Arc = + Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() }); + let engine = make_engine(channel_repo.clone(), schedule_repo); + + tick(&engine, &channel_repo).await; + + assert_eq!(saved.lock().unwrap().len(), 0); + } + + #[tokio::test] + async fn test_expiring_schedule_seamless_handoff() { + let ch = make_channel(); + let valid_until = Utc::now() + Duration::hours(20); + let schedule = make_schedule(ch.id, valid_until); + let saved = Arc::new(Mutex::new(vec![])); + let channel_repo: Arc = Arc::new(MockChannelRepo { channels: vec![ch] }); + let schedule_repo: Arc = + Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() }); + let engine = make_engine(channel_repo.clone(), schedule_repo); + + tick(&engine, &channel_repo).await; + + let saved = saved.lock().unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].valid_from, valid_until); + } + + #[tokio::test] + async fn test_expired_schedule_generates_from_now() { + let ch = make_channel(); + let valid_until = Utc::now() - Duration::hours(1); + let schedule = make_schedule(ch.id, valid_until); + let saved = Arc::new(Mutex::new(vec![])); + let channel_repo: Arc = Arc::new(MockChannelRepo { channels: vec![ch] }); + let schedule_repo: Arc = + Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() }); + let engine = make_engine(channel_repo.clone(), schedule_repo); + + tick(&engine, &channel_repo).await; + + let saved = saved.lock().unwrap(); + assert_eq!(saved.len(), 1); + let diff = (saved[0].valid_from - Utc::now()).num_seconds().abs(); + assert!(diff < 5, "valid_from should be ~now, diff={diff}"); + } +}