#![allow(clippy::await_holding_lock)]
use crate::downloads::DownloadQueue;
#[tokio::test]
async fn queue_starts_empty() {
let queue = DownloadQueue::new_for_test();
let listing = queue.listing().await;
assert!(listing.active.is_none());
assert!(listing.active_jobs.is_empty());
assert!(listing.queued.is_empty());
assert!(listing.history.is_empty());
}
#[tokio::test]
async fn enqueue_unknown_model_errors() {
let queue = DownloadQueue::new_for_test();
let err = queue.enqueue("no-such-model:xyz".to_string()).await;
assert!(err.is_err(), "expected unknown-model error");
}
use crate::downloads::{spawn_driver, PullDriver, RecipePayload, RecipePullDriver};
use mold_core::types::{DownloadEvent, JobStatus};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
struct NoopRecipeDriver;
#[async_trait::async_trait]
impl RecipePullDriver for NoopRecipeDriver {
async fn pull(
&self,
_payload: RecipePayload,
_models_dir: std::path::PathBuf,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
Err("noop recipe driver".into())
}
}
fn test_models_dir() -> std::path::PathBuf {
std::env::temp_dir().join(format!("mold_dl_test_{}", uuid::Uuid::new_v4().simple()))
}
fn spawn_test_driver(
queue: Arc<DownloadQueue>,
driver: Arc<dyn PullDriver>,
shutdown: CancellationToken,
) -> tokio::task::JoinHandle<()> {
spawn_driver(
queue,
driver,
Arc::new(NoopRecipeDriver),
test_models_dir(),
shutdown,
)
}
#[derive(Clone, Default)]
struct FakePuller {
pub called: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl PullDriver for FakePuller {
async fn pull(
&self,
_model: &str,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String> {
self.called.store(true, Ordering::SeqCst);
on_progress(mold_core::download::DownloadProgressEvent::Status {
message: "starting".into(),
});
on_progress(mold_core::download::DownloadProgressEvent::FileStart {
filename: "transformer.gguf".into(),
file_index: 0,
total_files: 1,
size_bytes: 1_000,
batch_bytes_downloaded: 0,
batch_bytes_total: 1_000,
batch_elapsed_ms: 0,
});
on_progress(mold_core::download::DownloadProgressEvent::FileProgress {
filename: "transformer.gguf".into(),
file_index: 0,
bytes_downloaded: 500,
bytes_total: 1_000,
batch_bytes_downloaded: 500,
batch_bytes_total: 1_000,
batch_elapsed_ms: 5,
});
on_progress(mold_core::download::DownloadProgressEvent::FileDone {
filename: "transformer.gguf".into(),
file_index: 0,
total_files: 1,
batch_bytes_downloaded: 1_000,
batch_bytes_total: 1_000,
batch_elapsed_ms: 10,
});
if cancel.is_cancelled() {
return Err("cancelled".into());
}
Ok(())
}
}
#[tokio::test]
async fn driver_happy_path_emits_started_progress_jobdone() {
let queue = DownloadQueue::new();
let puller = FakePuller::default();
let shutdown = CancellationToken::new();
let mut rx = queue.subscribe();
let driver_handle =
spawn_test_driver(queue.clone(), Arc::new(puller.clone()), shutdown.clone());
let (id, _pos, _outcome) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut seen_started = false;
let mut seen_progress = false;
let mut seen_done = false;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(DownloadEvent::Started { id: ev_id, .. })) if ev_id == id => {
seen_started = true;
}
Ok(Ok(DownloadEvent::Progress { id: ev_id, .. })) if ev_id == id => {
seen_progress = true;
}
Ok(Ok(DownloadEvent::JobDone { id: ev_id, .. })) if ev_id == id => {
seen_done = true;
break;
}
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => {}
}
}
shutdown.cancel();
let _ = driver_handle.await;
assert!(
puller.called.load(Ordering::SeqCst),
"puller was not invoked"
);
assert!(seen_started, "missing Started event");
assert!(seen_progress, "missing Progress event");
assert!(seen_done, "missing JobDone event");
let listing = queue.listing().await;
assert!(listing.active.is_none());
assert_eq!(listing.history.len(), 1);
assert_eq!(listing.history[0].status, JobStatus::Completed);
}
#[derive(Clone, Default)]
struct SlowPuller;
#[async_trait::async_trait]
impl PullDriver for SlowPuller {
async fn pull(
&self,
_model: &str,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String> {
cancel.cancelled().await;
Err("cancelled".into())
}
}
#[tokio::test]
async fn cancel_active_emits_job_cancelled_and_clears_active() {
let queue = DownloadQueue::new();
let shutdown = CancellationToken::new();
let driver = spawn_test_driver(queue.clone(), Arc::new(SlowPuller), shutdown.clone());
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(queue.listing().await.active.is_some());
assert!(queue.cancel(&id).await, "cancel should find the active job");
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut seen = false;
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(DownloadEvent::JobCancelled { id: ev_id })) =
tokio::time::timeout(Duration::from_millis(200), rx.recv()).await
{
if ev_id == id {
seen = true;
break;
}
}
}
assert!(seen, "expected JobCancelled event");
shutdown.cancel();
let _ = driver.await;
let listing = queue.listing().await;
assert!(listing.active.is_none());
assert_eq!(listing.history.last().unwrap().status, JobStatus::Cancelled);
}
#[tokio::test]
async fn driver_runs_two_downloads_in_parallel() {
let queue = DownloadQueue::new();
let shutdown = CancellationToken::new();
let driver = spawn_test_driver(queue.clone(), Arc::new(SlowPuller), shutdown.clone());
let (first, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let (second, _, _) = queue.enqueue("flux-dev:q4".into()).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if queue.listing().await.active_jobs.len() == 2 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"both downloads should become active"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(queue.cancel(&first).await);
assert!(queue.cancel(&second).await);
shutdown.cancel();
let _ = driver.await;
}
#[tokio::test]
async fn cancel_queued_moves_to_history_and_emits_cancelled() {
let queue = DownloadQueue::new();
let (id_a, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let (id_b, _, _) = queue.enqueue("flux-dev:q4".into()).await.unwrap();
assert_eq!(queue.listing().await.queued.len(), 2);
let mut rx = queue.subscribe();
assert!(queue.cancel(&id_b).await);
let got = tokio::time::timeout(Duration::from_millis(200), rx.recv())
.await
.expect("timed out waiting for Dequeued")
.expect("broadcast channel closed");
match got {
DownloadEvent::JobCancelled { id } => assert_eq!(id, id_b),
other => panic!("expected JobCancelled, got {other:?}"),
}
let listing = queue.listing().await;
assert_eq!(listing.queued.len(), 1);
assert_eq!(listing.queued[0].id, id_a);
assert_eq!(listing.history.last().unwrap().status, JobStatus::Cancelled);
}
#[tokio::test]
async fn cancel_unknown_id_returns_false() {
let queue = DownloadQueue::new();
assert!(!queue.cancel("no-such-id").await);
}
#[test]
fn cleanup_preserves_sha256_verified_files_and_deletes_partials() {
use crate::downloads::cleanup_partials_in_dir;
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path();
let verified = dir.join("transformer.gguf");
let verified_marker = dir.join("transformer.gguf.sha256-verified");
std::fs::write(&verified, b"verified payload").unwrap();
std::fs::write(&verified_marker, b"").unwrap();
let partial = dir.join("vae.safetensors");
std::fs::write(&partial, b"partial payload").unwrap();
let orphan_marker = dir.join("orphan.bin.sha256-verified");
std::fs::write(&orphan_marker, b"").unwrap();
cleanup_partials_in_dir(dir);
assert!(
verified.exists(),
"verified file should be preserved (has .sha256-verified marker)"
);
assert!(
verified_marker.exists(),
".sha256-verified marker should be preserved"
);
assert!(
!partial.exists(),
"partial file (no marker) should be deleted"
);
assert!(
orphan_marker.exists(),
"orphan .sha256-verified marker should be preserved"
);
}
#[test]
fn cleanup_handles_missing_dir_gracefully() {
use crate::downloads::cleanup_partials_in_dir;
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("does-not-exist");
cleanup_partials_in_dir(&missing);
assert!(!missing.exists());
}
#[cfg(unix)]
#[test]
fn cleanup_does_not_follow_symlinks_outside_models_dir() {
use crate::downloads::cleanup_partials_in_dir;
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().expect("tempdir");
let models_dir = tmp.path().join("models");
let model_dir = models_dir.join("flux-schnell-q4");
std::fs::create_dir_all(&model_dir).unwrap();
let outside_dir = tmp.path().join("outside");
std::fs::create_dir_all(&outside_dir).unwrap();
let outside_file = outside_dir.join("precious.bin");
std::fs::write(&outside_file, b"do not delete me").unwrap();
let partial = model_dir.join("vae.safetensors");
std::fs::write(&partial, b"partial payload").unwrap();
let file_link = model_dir.join("escape-file.bin");
symlink(&outside_file, &file_link).unwrap();
let dir_link = model_dir.join("escape-dir");
symlink(&outside_dir, &dir_link).unwrap();
cleanup_partials_in_dir(&model_dir);
assert!(
outside_file.exists(),
"outside file must not be deleted by cleanup"
);
assert!(
outside_dir.exists(),
"outside directory must not be deleted by cleanup"
);
assert!(!partial.exists(), "real partial should be deleted");
let _ = file_link;
let _ = dir_link;
}
static CLEANUP_OBSERVER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Clone, Default)]
struct AlwaysFailsPuller {
pub calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl PullDriver for AlwaysFailsPuller {
async fn pull(
&self,
_model: &str,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err("simulated terminal failure".into())
}
}
#[tokio::test]
async fn driver_failed_retry_sequence_cleans_up_partials() {
let _guard = CLEANUP_OBSERVER_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let _ = crate::downloads::test_hooks::drain_cleanups();
let queue = DownloadQueue::new();
let puller = AlwaysFailsPuller::default();
let shutdown = CancellationToken::new();
let handle = spawn_test_driver(queue.clone(), Arc::new(puller.clone()), shutdown.clone());
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(12);
let mut seen_failed = false;
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(DownloadEvent::JobFailed { id: ev, .. })) =
tokio::time::timeout(Duration::from_millis(500), rx.recv()).await
{
if ev == id {
seen_failed = true;
break;
}
}
}
shutdown.cancel();
let _ = handle.await;
assert!(seen_failed, "expected JobFailed after retry");
assert_eq!(
puller.calls.load(Ordering::SeqCst),
2,
"expected exactly 2 attempts"
);
let cleanups = crate::downloads::test_hooks::drain_cleanups();
assert!(
cleanups.iter().any(|m| m == "flux-schnell:q4"),
"cleanup_partials_for_model should have been invoked for the failed model, got {cleanups:?}"
);
}
#[derive(Clone, Default)]
struct CancellablePuller;
#[async_trait::async_trait]
impl PullDriver for CancellablePuller {
async fn pull(
&self,
_model: &str,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String> {
cancel.cancelled().await;
Err("cancelled".into())
}
}
#[tokio::test]
async fn driver_cancel_also_cleans_up_partials() {
let _guard = CLEANUP_OBSERVER_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let _ = crate::downloads::test_hooks::drain_cleanups();
let queue = DownloadQueue::new();
let shutdown = CancellationToken::new();
let handle = spawn_test_driver(queue.clone(), Arc::new(CancellablePuller), shutdown.clone());
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(queue.cancel(&id).await);
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut seen = false;
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(DownloadEvent::JobCancelled { id: ev_id })) =
tokio::time::timeout(Duration::from_millis(200), rx.recv()).await
{
if ev_id == id {
seen = true;
break;
}
}
}
shutdown.cancel();
let _ = handle.await;
assert!(seen, "expected JobCancelled");
let cleanups = crate::downloads::test_hooks::drain_cleanups();
assert!(
cleanups.iter().any(|m| m == "flux-schnell:q4"),
"cleanup_partials_for_model should have been invoked for the cancelled model, got {cleanups:?}"
);
}
#[derive(Clone, Default)]
struct FlakyPuller {
pub calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl PullDriver for FlakyPuller {
async fn pull(
&self,
_model: &str,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
on_progress(mold_core::download::DownloadProgressEvent::FileStart {
filename: "f".into(),
file_index: 0,
total_files: 1,
size_bytes: 10,
batch_bytes_downloaded: 0,
batch_bytes_total: 10,
batch_elapsed_ms: 0,
});
if n == 1 {
Err("simulated network blip".into())
} else {
on_progress(mold_core::download::DownloadProgressEvent::FileDone {
filename: "f".into(),
file_index: 0,
total_files: 1,
batch_bytes_downloaded: 10,
batch_bytes_total: 10,
batch_elapsed_ms: 1,
});
Ok(())
}
}
}
#[derive(Clone, Default)]
struct MultiFilePuller;
#[async_trait::async_trait]
impl PullDriver for MultiFilePuller {
async fn pull(
&self,
_model: &str,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
use mold_core::download::DownloadProgressEvent as P;
on_progress(P::FileStart {
filename: "a.safetensors".into(),
file_index: 0,
total_files: 2,
size_bytes: 100,
batch_bytes_downloaded: 0,
batch_bytes_total: 200,
batch_elapsed_ms: 0,
});
on_progress(P::FileProgress {
filename: "a.safetensors".into(),
file_index: 0,
bytes_downloaded: 50,
bytes_total: 100,
batch_bytes_downloaded: 50,
batch_bytes_total: 200,
batch_elapsed_ms: 1,
});
on_progress(P::FileDone {
filename: "a.safetensors".into(),
file_index: 0,
total_files: 2,
batch_bytes_downloaded: 100,
batch_bytes_total: 200,
batch_elapsed_ms: 2,
});
on_progress(P::FileStart {
filename: "b.safetensors".into(),
file_index: 1,
total_files: 2,
size_bytes: 100,
batch_bytes_downloaded: 100,
batch_bytes_total: 200,
batch_elapsed_ms: 3,
});
on_progress(P::FileProgress {
filename: "b.safetensors".into(),
file_index: 1,
bytes_downloaded: 25,
bytes_total: 100,
batch_bytes_downloaded: 125,
batch_bytes_total: 200,
batch_elapsed_ms: 4,
});
on_progress(P::FileProgress {
filename: "b.safetensors".into(),
file_index: 1,
bytes_downloaded: 75,
bytes_total: 100,
batch_bytes_downloaded: 175,
batch_bytes_total: 200,
batch_elapsed_ms: 5,
});
on_progress(P::FileDone {
filename: "b.safetensors".into(),
file_index: 1,
total_files: 2,
batch_bytes_downloaded: 200,
batch_bytes_total: 200,
batch_elapsed_ms: 6,
});
Ok(())
}
}
#[tokio::test]
async fn progress_events_carry_current_files_done_counter() {
let queue = DownloadQueue::new();
let shutdown = CancellationToken::new();
let handle = spawn_test_driver(queue.clone(), Arc::new(MultiFilePuller), shutdown.clone());
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let mut progress_samples: Vec<(usize, u64)> = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(DownloadEvent::Progress {
id: ev_id,
files_done,
bytes_done,
..
})) if ev_id == id => {
progress_samples.push((files_done, bytes_done));
}
Ok(Ok(DownloadEvent::JobDone { id: ev_id, .. })) if ev_id == id => {
break;
}
_ => {}
}
}
shutdown.cancel();
let _ = handle.await;
assert!(
progress_samples.len() >= 3,
"expected at least 3 Progress events, got {progress_samples:?}"
);
assert_eq!(
progress_samples[0].0, 0,
"first Progress (mid file 0) should report files_done = 0, got {progress_samples:?}",
);
let after_first_done = &progress_samples[1..];
for (files_done, _) in after_first_done {
assert!(
*files_done >= 1,
"Progress events after the first FileDone must carry files_done >= 1; \
got sequence {progress_samples:?}",
);
}
}
#[tokio::test]
async fn driver_retries_once_then_succeeds() {
let queue = DownloadQueue::new();
let puller = FlakyPuller::default();
let shutdown = CancellationToken::new();
let handle = spawn_test_driver(queue.clone(), Arc::new(puller.clone()), shutdown.clone());
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(12);
let mut seen_done = false;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Ok(DownloadEvent::JobDone { id: ev, .. })) if ev == id => {
seen_done = true;
break;
}
Ok(Ok(DownloadEvent::JobFailed { .. })) => {
panic!("expected JobDone after retry, got JobFailed");
}
_ => {}
}
}
shutdown.cancel();
let _ = handle.await;
assert!(seen_done, "expected JobDone after retry");
assert_eq!(
puller.calls.load(Ordering::SeqCst),
2,
"expected exactly 2 attempts"
);
}
use crate::downloads::OwnedRecipeFile;
fn synthetic_recipe(catalog_id: &str) -> RecipePayload {
RecipePayload {
catalog_id: catalog_id.to_string(),
files: vec![OwnedRecipeFile {
url: "http://recipe.example.invalid/primary.safetensors".to_string(),
dest: "primary.safetensors".to_string(),
sha256: None,
size_bytes: Some(123),
}],
auth: mold_core::download::RecipeAuth::None,
}
}
#[tokio::test]
async fn enqueue_recipe_returns_job_id() {
let queue = DownloadQueue::new_for_test();
let payload = synthetic_recipe("cv:618692");
let (job_id, position, outcome) = queue
.enqueue_recipe(payload.clone())
.await
.expect("enqueue_recipe must succeed");
assert!(!job_id.is_empty());
assert_eq!(position, 1);
assert_eq!(outcome, crate::downloads::EnqueueOutcome::Created);
let listing = queue.listing().await;
assert_eq!(listing.queued.len(), 1);
assert_eq!(listing.queued[0].id, job_id);
assert_eq!(listing.queued[0].model, "cv:618692");
}
#[tokio::test]
async fn enqueue_recipe_idempotent_on_same_catalog_id() {
let queue = DownloadQueue::new_for_test();
let payload = synthetic_recipe("cv:42");
let (id1, _, outcome1) = queue.enqueue_recipe(payload.clone()).await.unwrap();
let (id2, _, outcome2) = queue.enqueue_recipe(payload).await.unwrap();
assert_eq!(
id1, id2,
"duplicate enqueue must return the existing job id"
);
assert_eq!(outcome1, crate::downloads::EnqueueOutcome::Created);
assert_eq!(outcome2, crate::downloads::EnqueueOutcome::AlreadyPresent);
let listing = queue.listing().await;
assert_eq!(listing.queued.len(), 1, "no duplicate job should be queued");
}
#[tokio::test]
async fn enqueue_recipe_rejects_blank_catalog_id() {
let queue = DownloadQueue::new_for_test();
let mut payload = synthetic_recipe("cv:1");
payload.catalog_id = " ".into();
let err = queue.enqueue_recipe(payload).await;
assert!(err.is_err(), "blank catalog id must error");
}
#[derive(Clone, Default)]
struct CapturingRecipeDriver {
pub captured: Arc<std::sync::Mutex<Option<RecipePayload>>>,
}
#[async_trait::async_trait]
impl RecipePullDriver for CapturingRecipeDriver {
async fn pull(
&self,
payload: RecipePayload,
_models_dir: std::path::PathBuf,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
*self.captured.lock().unwrap() = Some(payload);
Ok(())
}
}
#[derive(Clone, Default)]
struct UnexpectedManifestDriver {
pub called: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl PullDriver for UnexpectedManifestDriver {
async fn pull(
&self,
_model: &str,
_on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
_cancel: CancellationToken,
) -> Result<(), String> {
self.called.store(true, Ordering::SeqCst);
Err("manifest driver should not have been invoked for a recipe job".into())
}
}
#[tokio::test]
async fn recipe_job_dispatches_to_recipe_driver_not_manifest_driver() {
let queue = DownloadQueue::new();
let recipe_driver = CapturingRecipeDriver::default();
let manifest_driver = UnexpectedManifestDriver::default();
let shutdown = CancellationToken::new();
let models_dir = test_models_dir();
let handle = spawn_driver(
queue.clone(),
Arc::new(manifest_driver.clone()),
Arc::new(recipe_driver.clone()),
models_dir,
shutdown.clone(),
);
let mut rx = queue.subscribe();
let payload = synthetic_recipe("cv:99");
let (job_id, _, _) = queue.enqueue_recipe(payload.clone()).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut seen_done = false;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(DownloadEvent::JobDone { id: ev, .. })) if ev == job_id => {
seen_done = true;
break;
}
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => {}
}
}
shutdown.cancel();
let _ = handle.await;
assert!(seen_done, "recipe job should complete via recipe driver");
assert!(
!manifest_driver.called.load(Ordering::SeqCst),
"manifest driver must not be invoked for recipe jobs"
);
let captured = recipe_driver.captured.lock().unwrap();
let captured = captured.as_ref().expect("recipe driver should have run");
assert_eq!(captured.catalog_id, "cv:99");
}
fn drain_catalog_ready(
rx: &mut tokio::sync::broadcast::Receiver<DownloadEvent>,
) -> Vec<(String, bool)> {
let mut out = Vec::new();
loop {
match rx.try_recv() {
Ok(DownloadEvent::CatalogReady { id, ok }) => out.push((id, ok)),
Ok(_) => continue,
Err(_) => break,
}
}
out
}
#[tokio::test]
async fn catalog_group_emits_ready_when_all_jobs_complete() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (j1, _, _) = queue
.enqueue_in_group("flux-schnell:q4".into(), "cv:42")
.await
.unwrap();
let (j2, _, _) = queue
.enqueue_in_group("flux-dev:q4".into(), "cv:42")
.await
.unwrap();
let (j3, _, _) = queue
.enqueue_in_group("real-esrgan-x4plus:fp16".into(), "cv:42")
.await
.unwrap();
queue.settle_for_group(&j1, JobStatus::Completed);
queue.settle_for_group(&j2, JobStatus::Completed);
assert!(
drain_catalog_ready(&mut rx).is_empty(),
"no event until last job settles"
);
queue.settle_for_group(&j3, JobStatus::Completed);
let ready = drain_catalog_ready(&mut rx);
assert_eq!(ready, vec![("cv:42".to_string(), true)]);
}
#[tokio::test]
async fn catalog_group_emits_ready_with_ok_false_on_failure() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (j1, _, _) = queue
.enqueue_in_group("flux-schnell:q4".into(), "cv:99")
.await
.unwrap();
let (j2, _, _) = queue
.enqueue_in_group("flux-dev:q4".into(), "cv:99")
.await
.unwrap();
queue.settle_for_group(&j1, JobStatus::Completed);
queue.settle_for_group(&j2, JobStatus::Failed);
let ready = drain_catalog_ready(&mut rx);
assert_eq!(ready, vec![("cv:99".to_string(), false)]);
}
#[tokio::test]
async fn catalog_group_emits_ready_with_ok_false_on_cancellation() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (j1, _, _) = queue
.enqueue_in_group("flux-schnell:q4".into(), "cv:7")
.await
.unwrap();
let (j2, _, _) = queue
.enqueue_in_group("flux-dev:q4".into(), "cv:7")
.await
.unwrap();
queue.settle_for_group(&j1, JobStatus::Completed);
queue.settle_for_group(&j2, JobStatus::Cancelled);
let ready = drain_catalog_ready(&mut rx);
assert_eq!(ready, vec![("cv:7".to_string(), false)]);
}
#[tokio::test]
async fn catalog_group_emits_ready_only_once() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (j1, _, _) = queue
.enqueue_in_group("flux-schnell:q4".into(), "cv:1")
.await
.unwrap();
queue.settle_for_group(&j1, JobStatus::Completed);
queue.settle_for_group(&j1, JobStatus::Completed);
queue.settle_for_group(&j1, JobStatus::Completed);
let ready = drain_catalog_ready(&mut rx);
assert_eq!(ready.len(), 1);
}
#[tokio::test]
async fn settle_for_group_is_no_op_for_ungrouped_jobs() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (id, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
queue.settle_for_group(&id, JobStatus::Completed);
assert!(
drain_catalog_ready(&mut rx).is_empty(),
"ungrouped jobs must not produce CatalogReady"
);
}
#[tokio::test]
async fn enqueue_in_group_dedupes_membership_for_already_present() {
let queue = DownloadQueue::new_for_test();
let mut rx = queue.subscribe();
let (j_pre, _, _) = queue.enqueue("flux-schnell:q4".into()).await.unwrap();
let (j_in_group, _, outcome) = queue
.enqueue_in_group("flux-schnell:q4".into(), "cv:5")
.await
.unwrap();
assert_eq!(j_pre, j_in_group, "dedup must return the same job id");
assert_eq!(
outcome,
crate::downloads::EnqueueOutcome::AlreadyPresent,
"second enqueue should be AlreadyPresent"
);
queue.settle_for_group(&j_in_group, JobStatus::Completed);
let ready = drain_catalog_ready(&mut rx);
assert_eq!(ready, vec![("cv:5".to_string(), true)]);
}