• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

endoze / valheim-mod-manager / 14433898582

13 Apr 2025 10:22PM UTC coverage: 87.216%. First build
14433898582

push

github

web-flow
Merge pull request #1 from endoze/fix-write-cache-to-disk-race-condition

Fix: write_cache_to_disk race condition

16 of 18 new or added lines in 1 file covered. (88.89%)

307 of 352 relevant lines covered (87.22%)

2.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.41
/src/api.rs
1
use crate::error::{AppError, AppResult};
2
use crate::package::Package;
3

4
use chrono::prelude::*;
5
use futures::stream::{FuturesUnordered, StreamExt};
6
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
7
use reqwest::Client;
8
use reqwest::header;
9
use std::collections::HashMap;
10
use std::path::{Path, PathBuf};
11
use std::time::Duration;
12
use tokio::fs;
13
use tokio::io::{AsyncReadExt, AsyncWriteExt};
14

15
const API_URL: &str = "https://stacklands.thunderstore.io/c/valheim/api/v1/package/";
16
const LAST_MODIFIED_FILENAME: &str = "last_modified";
17
const API_MANIFEST_FILENAME: &str = "api_manifest.bin.zst";
18

19
/// Returns the path to the last_modified file in the cache directory.
20
fn last_modified_path(cache_dir: &str) -> PathBuf {
1✔
21
  let expanded_path = shellexpand::tilde(cache_dir);
1✔
22
  let mut path = PathBuf::from(expanded_path.as_ref());
2✔
23
  path.push(LAST_MODIFIED_FILENAME);
1✔
24
  path
1✔
25
}
26

27
/// Returns the path to the api_manifest file in the cache directory.
28
fn api_manifest_path(cache_dir: &str) -> PathBuf {
1✔
29
  let expanded_path = shellexpand::tilde(cache_dir);
1✔
30
  let mut path = PathBuf::from(expanded_path.as_ref());
2✔
31
  path.push(API_MANIFEST_FILENAME);
1✔
32
  path
1✔
33
}
34

35
/// Retrieves the manifest of available packages.
36
///
37
/// This function first checks if there's a cached manifest file that is up-to-date.
38
/// If a cached version exists and is current, it loads from disk.
39
/// Otherwise, it downloads the manifest from the network and caches it.
40
///
41
/// # Parameters
42
///
43
/// * `cache_dir` - The directory to store cache files in
44
/// * `api_url` - The API URL to use for network requests (defaults to API_URL)
45
///
46
/// # Returns
47
///
48
/// A `HashMap` where keys are package full names and values are the corresponding `Package` objects.
49
///
50
/// # Errors
51
///
52
/// Returns an error if:
53
/// - Failed to check or retrieve last modified dates
54
/// - Network request fails
55
/// - Parsing fails
56
pub async fn get_manifest(
1✔
57
  cache_dir: &str,
58
  api_url: Option<&str>,
59
) -> AppResult<HashMap<String, Package>> {
60
  let api_url = api_url.unwrap_or(API_URL);
2✔
61
  let last_modified = local_last_modified(cache_dir).await?;
2✔
62
  tracing::info!("Manifest last modified: {}", last_modified);
3✔
63

64
  let packages = if api_manifest_file_exists(cache_dir)
3✔
65
    && network_last_modified(api_url).await? <= last_modified
2✔
66
  {
67
    tracing::info!("Loading manifest from cache");
3✔
68
    get_manifest_from_disk(cache_dir).await?
3✔
69
  } else {
NEW
70
    tracing::info!("Downloading new manifest");
×
NEW
71
    get_manifest_from_network_and_cache(cache_dir, api_url).await?
×
72
  };
73

74
  Ok(
75
    packages
2✔
76
      .into_iter()
77
      .filter_map(|pkg| {
1✔
78
        let name = pkg.full_name.clone()?;
2✔
79

80
        Some((name, pkg))
1✔
81
      })
82
      .collect(),
83
  )
84
}
85

86
/// Reads the cached API manifest from disk.
87
///
88
/// # Returns
89
///
90
/// The deserialized manifest as a Vec<Package>.
91
///
92
/// # Errors
93
///
94
/// Returns an error if:
95
/// - The file cannot be opened or read
96
/// - The data cannot be decompressed or deserialized
97
async fn get_manifest_from_disk(cache_dir: &str) -> AppResult<Vec<Package>> {
4✔
98
  let path = api_manifest_path(cache_dir);
1✔
99
  let mut file = fs::File::open(path).await?;
2✔
100
  let mut compressed_data = Vec::new();
1✔
101
  file.read_to_end(&mut compressed_data).await?;
4✔
102

103
  let decompressed_data = zstd::decode_all(compressed_data.as_slice())
4✔
104
    .map_err(|e| AppError::Manifest(format!("Failed to decompress manifest: {}", e)))?;
4✔
105

106
  let packages: Vec<Package> = bincode::deserialize(&decompressed_data)
2✔
107
    .map_err(|e| AppError::Manifest(format!("Failed to deserialize manifest: {}", e)))?;
×
108

109
  Ok(packages)
1✔
110
}
111

112
/// Downloads the manifest from the API server and filters out unnecessary fields.
113
///
114
/// This function shows a progress bar while downloading the manifest.
115
/// It parses the downloaded data to filter out fields marked with #[serde(skip_deserializing)],
116
/// which helps reduce the size of stored data on disk.
117
///
118
/// # Parameters
119
///
120
/// * `api_url` - The API URL to download the manifest from
121
///
122
/// # Returns
123
///
124
/// A tuple containing:
125
/// - The filtered manifest data as a vector of Packages (with unnecessary fields removed)
126
/// - The last modified date from the response headers
127
///
128
/// # Errors
129
///
130
/// Returns an error if:
131
/// - Network request fails
132
/// - Response headers cannot be parsed
133
/// - Last modified date cannot be parsed
134
/// - JSON parsing or serialization fails
135
async fn get_manifest_from_network(
1✔
136
  api_url: &str,
137
) -> AppResult<(Vec<Package>, DateTime<FixedOffset>)> {
138
  let client = Client::builder().build()?;
2✔
139

140
  let multi_progress = MultiProgress::new();
4✔
141
  let style_template = "";
1✔
142
  let progress_style = ProgressStyle::with_template(style_template)
4✔
143
    .unwrap()
144
    .tick_strings(&["-", "\\", "|", "/", ""]);
2✔
145

146
  let progress_bar = multi_progress.add(ProgressBar::new(100000));
4✔
147
  progress_bar.set_style(progress_style);
2✔
148
  progress_bar.set_message("Downloading Api Manifest");
2✔
149
  progress_bar.enable_steady_tick(Duration::from_millis(130));
2✔
150

151
  let response = client.get(api_url).send().await?;
3✔
152
  let last_modified_str = response
3✔
153
    .headers()
154
    .get(header::LAST_MODIFIED)
155
    .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
1✔
156
    .to_str()?;
157
  let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
2✔
158

159
  let raw_response_data = response.bytes().await?;
2✔
160

161
  let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
2✔
162

163
  progress_bar.finish_with_message(" Downloaded Api Manifest");
1✔
164

165
  Ok((packages, last_modified))
2✔
166
}
167

168
/// Downloads the manifest from the network and caches it locally.
169
///
170
/// This function retrieves the manifest from the API server and saves both
171
/// the manifest content and the last modified date to disk.
172
///
173
/// # Parameters
174
///
175
/// * `cache_dir` - The directory to store cache files in
176
/// * `api_url` - The API URL to download the manifest from
177
///
178
/// # Returns
179
///
180
/// The manifest data as Vec<Package>.
181
///
182
/// # Errors
183
///
184
/// Returns an error if:
185
/// - Network request fails
186
/// - Writing cache files fails
187
async fn get_manifest_from_network_and_cache(
1✔
188
  cache_dir: &str,
189
  api_url: &str,
190
) -> AppResult<Vec<Package>> {
191
  let results = get_manifest_from_network(api_url).await?;
3✔
192
  let packages = results.0;
1✔
193
  let last_modified_from_network = results.1;
1✔
194

195
  write_cache_to_disk(
196
    last_modified_path(cache_dir),
2✔
197
    last_modified_from_network.to_rfc2822().as_bytes(),
2✔
198
    false,
199
  )
200
  .await?;
5✔
201

202
  let binary_data = bincode::serialize(&packages)
1✔
203
    .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
×
204

205
  write_cache_to_disk(api_manifest_path(cache_dir), &binary_data, true).await?;
3✔
206

207
  Ok(packages)
1✔
208
}
209

210
/// Retrieves the last modified date from the local cache file.
211
///
212
/// If the file doesn't exist, returns a default date (epoch time).
213
///
214
/// # Returns
215
///
216
/// The last modified date as a `DateTime<FixedOffset>`.
217
///
218
/// # Errors
219
///
220
/// Returns an error if:
221
/// - The file exists but cannot be read
222
/// - The date string cannot be parsed
223
async fn local_last_modified(cache_dir: &str) -> AppResult<DateTime<FixedOffset>> {
4✔
224
  let path = last_modified_path(cache_dir);
1✔
225

226
  if let Ok(mut file) = fs::File::open(&path).await {
3✔
227
    tracing::info!("Last modified file exists and was opened.");
3✔
228
    let mut contents = String::new();
1✔
229
    file.read_to_string(&mut contents).await?;
3✔
230

231
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
2✔
232

233
    Ok(last_modified)
1✔
234
  } else {
235
    tracing::info!("Last modified file does not exist and was not opened.");
3✔
236
    let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
2✔
237
    let offset = FixedOffset::east_opt(0).unwrap();
2✔
238

239
    let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
1✔
240
    Ok(last_modified)
1✔
241
  }
242
}
243

244
/// Retrieves the last modified date from the API server.
245
///
246
/// Makes a HEAD request to the API endpoint to check when the manifest was last updated.
247
///
248
/// # Parameters
249
///
250
/// * `api_url` - The API URL to check
251
///
252
/// # Returns
253
///
254
/// The last modified date from the server as a `DateTime<FixedOffset>`.
255
///
256
/// # Errors
257
///
258
/// Returns an error if:
259
/// - Network request fails
260
/// - Last-Modified header is missing
261
/// - Date string cannot be parsed
262
async fn network_last_modified(api_url: &str) -> AppResult<DateTime<FixedOffset>> {
4✔
263
  let client = Client::builder().build()?;
2✔
264

265
  let response = client.head(api_url).send().await?;
3✔
266

267
  let last_modified =
3✔
268
    response
269
      .headers()
270
      .get(header::LAST_MODIFIED)
271
      .ok_or(AppError::MissingHeader(
1✔
272
        "Last-Modified for API manifest head request".to_string(),
1✔
273
      ))?;
274
  let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
2✔
275

276
  Ok(last_modified_date)
1✔
277
}
278

279
/// Checks if the cached manifest file exists.
280
///
281
/// # Returns
282
///
283
/// `true` if the api_manifest file exists, `false` otherwise.
284
fn api_manifest_file_exists(cache_dir: &str) -> bool {
1✔
285
  api_manifest_path(cache_dir).exists()
1✔
286
}
287

288
/// Writes data to a cache file on disk.
289
///
290
/// Creates the parent directory if it doesn't exist, then the file if it doesn't exist,
291
/// then writes the provided contents to it.
292
///
293
/// # Parameters
294
///
295
/// * `path` - The path to the cache file
296
/// * `contents` - The data to write to the file
297
/// * `use_compression` - If true, the data will be compressed with zstd
298
///
299
/// # Errors
300
///
301
/// Returns an error if:
302
/// - Directory creation fails
303
/// - File creation fails
304
/// - Opening the file for writing fails
305
/// - Writing to the file fails
306
/// - Compression fails
307
async fn write_cache_to_disk<T: AsRef<Path>>(
2✔
308
  path: T,
309
  contents: &[u8],
310
  use_compression: bool,
311
) -> AppResult<()> {
312
  if let Some(parent) = path.as_ref().parent() {
6✔
313
    fs::create_dir_all(parent).await?;
6✔
314
  }
315

316
  if !path.as_ref().exists() {
4✔
317
    fs::File::create(&path).await?;
6✔
318
  }
319

320
  let mut file = fs::OpenOptions::new()
12✔
321
    .write(true)
322
    .truncate(true)
323
    .open(&path)
2✔
324
    .await?;
8✔
325

326
  if use_compression {
4✔
327
    let compressed_data = zstd::encode_all(contents, 3)
4✔
328
      .map_err(|e| AppError::Manifest(format!("Failed to compress data: {}", e)))?;
×
329

330
    file.write_all(&compressed_data).await?;
6✔
331
    file.flush().await?;
7✔
332
  } else {
333
    file.write_all(contents).await?;
6✔
334
    file.flush().await?;
7✔
335
  }
336

337
  Ok(())
2✔
338
}
339

340
/// Downloads multiple files concurrently with progress indicators.
341
/// Skips files that already exist with the correct version.
342
///
343
/// # Parameters
344
///
345
/// * `urls` - A HashMap where keys are filenames and values are the URLs to download from
346
/// * `cache_dir` - The app's cache directory
347
///
348
/// # Notes
349
///
350
/// - Files are downloaded in parallel with a limit of 2 concurrent downloads
351
/// - Progress bars show download status for each file
352
/// - Files are saved in the 'cache_dir/downloads' directory
353
/// - Files that already exist with the correct version are skipped
354
pub async fn download_files(urls: HashMap<String, String>, cache_dir: &str) -> AppResult<()> {
4✔
355
  if urls.is_empty() {
2✔
356
    tracing::debug!("No files to download");
3✔
357

358
    return Ok(());
1✔
359
  }
360

361
  tracing::debug!("Processing {} mods", urls.len());
3✔
362

363
  let client = Client::builder().timeout(Duration::from_secs(60)).build()?;
1✔
364

365
  let multi_progress = MultiProgress::new();
2✔
366
  let style_template = "";
1✔
367
  let progress_style = ProgressStyle::with_template(style_template)
2✔
368
    .unwrap()
369
    .progress_chars("#>-");
370

371
  let futures: FuturesUnordered<_> = urls
2✔
372
    .into_iter()
373
    .map(|(archive_filename, url)| {
3✔
374
      let client = client.clone();
2✔
375
      let multi_progress = multi_progress.clone();
2✔
376
      let progress_style = progress_style.clone();
2✔
377

378
      let cache_dir = cache_dir.to_string();
1✔
379
      tokio::spawn(async move {
3✔
380
        download_file(
3✔
381
          client,
1✔
382
          &url,
1✔
383
          &archive_filename,
1✔
384
          multi_progress,
1✔
385
          progress_style,
1✔
386
          &cache_dir,
1✔
387
        )
388
        .await
5✔
389
      })
390
    })
391
    .collect();
392

393
  let responses = futures::stream::iter(futures)
3✔
394
    .buffer_unordered(2)
395
    .collect::<Vec<_>>()
396
    .await;
4✔
397

398
  for response in responses {
3✔
399
    match response {
2✔
400
      Ok(Ok(_)) => {}
401
      Ok(Err(err)) => {
×
402
        tracing::error!("Download error: {:?}", err);
×
403
      }
404
      Err(err) => {
×
405
        return Err(AppError::TaskFailed(err));
×
406
      }
407
    }
408
  }
409

410
  Ok(())
1✔
411
}
412

413
/// Downloads a single file from a URL with a progress indicator.
414
/// Checks if the file already exists before downloading to avoid duplicate downloads.
415
///
416
/// # Parameters
417
///
418
/// * `client` - The HTTP client to use for the download
419
/// * `url` - The URL to download from
420
/// * `filename` - The name to save the file as
421
/// * `multi_progress` - The multi-progress display for coordinating multiple progress bars
422
/// * `progress_style` - The style to use for the progress bar
423
/// * `cache_dir` - The app's cache directory
424
///
425
/// # Returns
426
///
427
/// `Ok(())` on successful download or if file already exists.
428
///
429
/// # Errors
430
///
431
/// Returns an error if:
432
/// - Network request fails
433
/// - Content-Length header is missing
434
/// - Creating directories or files fails
435
/// - Writing to the file fails
436
async fn download_file(
2✔
437
  client: reqwest::Client,
438
  url: &String,
439
  filename: &String,
440
  multi_progress: MultiProgress,
441
  progress_style: ProgressStyle,
442
  cache_dir: &str,
443
) -> AppResult<()> {
444
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
445
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
4✔
446
  downloads_directory.push("downloads");
2✔
447
  let mut file_path = downloads_directory.clone();
3✔
448
  file_path.push(filename);
1✔
449

450
  tokio::fs::DirBuilder::new()
8✔
451
    .recursive(true)
452
    .create(&downloads_directory)
1✔
453
    .await?;
4✔
454

455
  if file_path.exists() {
2✔
456
    tracing::debug!("{} already exists, skipping download", filename);
3✔
457

458
    return Ok(());
1✔
459
  }
460

461
  let response = client.get(url).send().await?;
3✔
462
  let content_length = response
3✔
463
    .headers()
464
    .get(header::CONTENT_LENGTH)
465
    .ok_or(AppError::MissingHeader(format!(
2✔
466
      "Content-Length header for {}",
467
      url
468
    )))?
469
    .to_str()?
470
    .parse::<u64>()?;
471
  let mut response_data = response.bytes_stream();
2✔
472

473
  let progress_bar = multi_progress.add(ProgressBar::new(content_length));
2✔
474
  progress_bar.set_style(progress_style);
1✔
475
  progress_bar.set_message(filename.clone());
1✔
476

477
  let mut file = tokio::fs::File::create(file_path).await?;
2✔
478

479
  while let Some(result) = response_data.next().await {
4✔
480
    let chunk = result?;
2✔
481
    file.write_all(&chunk).await?;
4✔
482
    progress_bar.inc(chunk.len() as u64);
2✔
483
  }
484

485
  progress_bar.finish();
1✔
486

487
  Ok(())
1✔
488
}
489

490
#[cfg(test)]
491
mod tests {
492
  use super::*;
493
  use mockito::Server;
494
  use std::fs::File;
495
  use std::io::Write;
496
  use tempfile::tempdir;
497
  use time::OffsetDateTime;
498
  use tokio::runtime::Runtime;
499

500
  #[test]
501
  fn test_downloads_directory_construction() {
502
    let temp_dir = tempdir().unwrap();
503
    let cache_dir = temp_dir.path().to_str().unwrap();
504

505
    let expanded_path = shellexpand::tilde(cache_dir);
506
    let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
507
    downloads_directory.push("downloads");
508

509
    let expected_directory = PathBuf::from(cache_dir).join("downloads");
510
    assert_eq!(downloads_directory, expected_directory);
511
  }
512

513
  #[test]
514
  fn test_api_manifest_file_exists() {
515
    let temp_dir = tempdir().unwrap();
516
    let temp_dir_str = temp_dir.path().to_str().unwrap();
517

518
    assert!(!api_manifest_file_exists(temp_dir_str));
519

520
    let mut path = PathBuf::from(temp_dir_str);
521
    path.push(API_MANIFEST_FILENAME);
522
    let _ = File::create(path).unwrap();
523

524
    assert!(api_manifest_file_exists(temp_dir_str));
525
  }
526

527
  #[test]
528
  fn test_path_with_tilde() {
529
    let home_path = "~/some_test_dir";
530
    let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
531
    let expected_path = Path::new(&home_dir).join("some_test_dir");
532

533
    let last_modified = last_modified_path(home_path);
534
    let api_manifest = api_manifest_path(home_path);
535

536
    assert_eq!(last_modified.parent().unwrap(), expected_path);
537
    assert_eq!(api_manifest.parent().unwrap(), expected_path);
538
  }
539

540
  #[test]
541
  fn test_write_cache_to_disk() {
542
    let temp_dir = tempdir().unwrap();
543
    let cache_path = temp_dir.path().join("test_cache.txt");
544
    let test_data = b"Test cache data";
545

546
    let rt = Runtime::new().unwrap();
547
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
548

549
    assert!(result.is_ok());
550
    assert!(cache_path.exists());
551

552
    let content = std::fs::read(&cache_path).unwrap();
553
    assert_eq!(content, test_data);
554

555
    let new_data = b"Updated cache data";
556
    let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
557

558
    assert!(result.is_ok());
559
    let content = std::fs::read(&cache_path).unwrap();
560

561
    assert_eq!(content, new_data);
562
  }
563

564
  #[test]
565
  fn test_write_cache_to_disk_creates_directories() {
566
    let temp_dir = tempdir().unwrap();
567

568
    let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
569
    let cache_path = nonexistent_subdir.join("test_cache.txt");
570

571
    if nonexistent_subdir.exists() {
572
      std::fs::remove_dir_all(&nonexistent_subdir).unwrap();
573
    }
574

575
    assert!(!nonexistent_subdir.exists());
576
    assert!(!cache_path.exists());
577

578
    let test_data = b"Test cache data";
579
    let rt = Runtime::new().unwrap();
580

581
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
582
    assert!(result.is_ok());
583

584
    assert!(
585
      nonexistent_subdir.exists(),
586
      "Directory structure should be created"
587
    );
588
    assert!(cache_path.exists(), "File should be created");
589

590
    let content = std::fs::read(&cache_path).unwrap();
591
    assert_eq!(content, test_data, "File should contain the expected data");
592
  }
593

594
  #[test]
595
  fn test_write_cache_to_disk_with_compression() {
596
    let temp_dir = tempdir().unwrap();
597
    let api_manifest_path = temp_dir.path().join("test_compressed.bin");
598

599
    let package = Package {
600
      name: Some("TestMod".to_string()),
601
      full_name: Some("TestOwner-TestMod".to_string()),
602
      owner: Some("TestOwner".to_string()),
603
      package_url: Some("https://example.com/TestMod".to_string()),
604
      date_created: OffsetDateTime::now_utc(),
605
      date_updated: OffsetDateTime::now_utc(),
606
      uuid4: Some("test-uuid".to_string()),
607
      rating_score: Some(5),
608
      is_pinned: Some(false),
609
      is_deprecated: Some(false),
610
      has_nsfw_content: Some(false),
611
      categories: vec!["test".to_string()],
612
      versions: vec![],
613
    };
614

615
    let packages = vec![package];
616
    let binary_data = bincode::serialize(&packages).unwrap();
617

618
    let rt = Runtime::new().unwrap();
619
    let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
620

621
    assert!(result.is_ok());
622
    assert!(api_manifest_path.exists());
623

624
    let compressed_data = std::fs::read(&api_manifest_path).unwrap();
625
    let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
626
    let decoded_packages: Vec<Package> = bincode::deserialize(&decompressed_data).unwrap();
627

628
    assert_eq!(decoded_packages.len(), 1);
629
    assert_eq!(
630
      decoded_packages[0].full_name,
631
      Some("TestOwner-TestMod".to_string())
632
    );
633
  }
634

635
  #[test]
636
  fn test_local_last_modified() {
637
    let temp_dir = tempdir().unwrap();
638
    let temp_dir_str = temp_dir.path().to_str().unwrap();
639
    let rt = Runtime::new().unwrap();
640

641
    {
642
      let result = rt.block_on(local_last_modified(temp_dir_str));
643

644
      assert!(result.is_ok());
645
    }
646

647
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
648
    let mut path = PathBuf::from(temp_dir_str);
649
    path.push(LAST_MODIFIED_FILENAME);
650
    let mut file = File::create(path).unwrap();
651
    file.write_all(test_date.as_bytes()).unwrap();
652

653
    {
654
      let result = rt.block_on(local_last_modified(temp_dir_str));
655

656
      assert!(result.is_ok());
657
    }
658
  }
659

660
  #[test]
661
  fn test_get_manifest_from_disk() {
662
    let temp_dir = tempdir().unwrap();
663
    let temp_dir_str = temp_dir.path().to_str().unwrap();
664

665
    let package = Package {
666
      name: Some("ModA".to_string()),
667
      full_name: Some("Owner-ModA".to_string()),
668
      owner: Some("Owner".to_string()),
669
      package_url: Some("https://example.com/ModA".to_string()),
670
      date_created: OffsetDateTime::now_utc(),
671
      date_updated: OffsetDateTime::now_utc(),
672
      uuid4: Some("test-uuid".to_string()),
673
      rating_score: Some(5),
674
      is_pinned: Some(false),
675
      is_deprecated: Some(false),
676
      has_nsfw_content: Some(false),
677
      categories: vec!["test".to_string()],
678
      versions: vec![],
679
    };
680

681
    let packages = vec![package];
682
    let binary_data = bincode::serialize(&packages).unwrap();
683
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 3).unwrap();
684
    let mut path = PathBuf::from(temp_dir_str);
685
    path.push(API_MANIFEST_FILENAME);
686
    let mut file = File::create(path).unwrap();
687
    file.write_all(&compressed_data).unwrap();
688

689
    let rt = Runtime::new().unwrap();
690
    let packages = rt.block_on(get_manifest_from_disk(temp_dir_str)).unwrap();
691

692
    assert_eq!(packages.len(), 1);
693
    assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
694
  }
695

696
  #[test]
697
  fn test_get_manifest_from_disk_error() {
698
    let temp_dir = tempdir().unwrap();
699
    let temp_dir_str = temp_dir.path().to_str().unwrap();
700

701
    let mut path = PathBuf::from(temp_dir_str);
702
    path.push(API_MANIFEST_FILENAME);
703
    let mut file = File::create(path).unwrap();
704
    file
705
      .write_all(b"This is not valid compressed data")
706
      .unwrap();
707

708
    let rt = Runtime::new().unwrap();
709
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
710

711
    assert!(result.is_err());
712
  }
713

714
  #[test]
715
  fn test_network_last_modified() {
716
    let mut server = Server::new();
717
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
718
    let mock = server
719
      .mock("HEAD", "/c/valheim/api/v1/package/")
720
      .with_status(200)
721
      .with_header("Last-Modified", test_date)
722
      .create();
723
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
724
    let rt = Runtime::new().unwrap();
725

726
    let result = rt.block_on(network_last_modified(&api_url));
727

728
    assert!(result.is_ok());
729
    if let Ok(parsed_date) = result {
730
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
731
      assert_eq!(parsed_date, expected_date);
732
    }
733

734
    mock.assert();
735
  }
736

737
  #[test]
738
  fn test_get_manifest_from_network() {
739
    let mut server = Server::new();
740

741
    let test_json = r#"[
742
      {
743
        "name": "ModA",
744
        "full_name": "Owner-ModA",
745
        "owner": "Owner",
746
        "package_url": "https://example.com/mods/ModA",
747
        "date_created": "2024-01-01T12:00:00Z",
748
        "date_updated": "2024-01-02T12:00:00Z",
749
        "uuid4": "test-uuid",
750
        "rating_score": 5,
751
        "is_pinned": false,
752
        "is_deprecated": false,
753
        "has_nsfw_content": false,
754
        "categories": ["category1"],
755
        "versions": [
756
          {
757
            "name": "ModA",
758
            "full_name": "Owner-ModA",
759
            "description": "Test description",
760
            "icon": "icon.png",
761
            "version_number": "1.0.0",
762
            "dependencies": [],
763
            "download_url": "https://example.com/mods/ModA/download",
764
            "downloads": 100,
765
            "date_created": "2024-01-01T12:00:00Z",
766
            "website_url": "https://example.com",
767
            "is_active": true,
768
            "uuid4": "test-version-uuid",
769
            "file_size": 1024
770
          }
771
        ]
772
      }
773
    ]"#;
774

775
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
776

777
    let mock = server
778
      .mock("GET", "/c/valheim/api/v1/package/")
779
      .with_status(200)
780
      .with_header("Content-Type", "application/json")
781
      .with_header("Last-Modified", test_date)
782
      .with_body(test_json)
783
      .create();
784

785
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
786

787
    let rt = Runtime::new().unwrap();
788
    let result = rt.block_on(get_manifest_from_network(&api_url));
789

790
    assert!(result.is_ok());
791
    if let Ok((packages, last_modified)) = result {
792
      assert_eq!(packages.len(), 1);
793
      assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
794
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
795
      assert_eq!(last_modified, expected_date);
796
    }
797

798
    mock.assert();
799
  }
800

801
  #[test]
802
  fn test_get_manifest() {
803
    let temp_dir = tempdir().unwrap();
804
    let temp_dir_str = temp_dir.path().to_str().unwrap();
805

806
    let package = Package {
807
      name: Some("CachedMod".to_string()),
808
      full_name: Some("CachedOwner-CachedMod".to_string()),
809
      owner: Some("CachedOwner".to_string()),
810
      package_url: Some("https://example.com/CachedMod".to_string()),
811
      date_created: OffsetDateTime::now_utc(),
812
      date_updated: OffsetDateTime::now_utc(),
813
      uuid4: Some("cached-uuid".to_string()),
814
      rating_score: Some(5),
815
      is_pinned: Some(false),
816
      is_deprecated: Some(false),
817
      has_nsfw_content: Some(false),
818
      categories: vec!["test".to_string()],
819
      versions: vec![],
820
    };
821

822
    let packages = vec![package];
823
    let binary_data = bincode::serialize(&packages).unwrap();
824
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 3).unwrap();
825

826
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
827

828
    let mut manifest_path = PathBuf::from(temp_dir_str);
829
    manifest_path.push(API_MANIFEST_FILENAME);
830
    let mut file = File::create(manifest_path).unwrap();
831
    file.write_all(&compressed_data).unwrap();
832

833
    let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
834
    let recent_date = now.to_rfc2822();
835
    let mut last_mod_path = PathBuf::from(temp_dir_str);
836
    last_mod_path.push(LAST_MODIFIED_FILENAME);
837
    let mut file = File::create(&last_mod_path).unwrap();
838
    file.write_all(recent_date.as_bytes()).unwrap();
839

840
    let rt = Runtime::new().unwrap();
841

842
    let result = rt.block_on(get_manifest(temp_dir_str, None));
843

844
    assert!(result.is_ok());
845
  }
846

847
  #[test]
848
  fn test_get_manifest_from_network_and_cache() {
849
    let mut server = Server::new();
850

851
    let test_json = r#"[
852
      {
853
        "name": "ModA",
854
        "full_name": "Owner-ModA",
855
        "owner": "Owner",
856
        "package_url": "https://example.com/mods/ModA",
857
        "date_created": "2024-01-01T12:00:00Z",
858
        "date_updated": "2024-01-02T12:00:00Z",
859
        "uuid4": "test-uuid",
860
        "rating_score": 5,
861
        "is_pinned": false,
862
        "is_deprecated": false,
863
        "has_nsfw_content": false,
864
        "categories": ["category1"],
865
        "versions": [
866
          {
867
            "name": "ModA",
868
            "full_name": "Owner-ModA",
869
            "description": "Test description",
870
            "icon": "icon.png",
871
            "version_number": "1.0.0",
872
            "dependencies": [],
873
            "download_url": "https://example.com/mods/ModA/download",
874
            "downloads": 100,
875
            "date_created": "2024-01-01T12:00:00Z",
876
            "website_url": "https://example.com",
877
            "is_active": true,
878
            "uuid4": "test-version-uuid",
879
            "file_size": 1024
880
          }
881
        ]
882
      }
883
    ]"#;
884

885
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
886

887
    let mock = server
888
      .mock("GET", "/c/valheim/api/v1/package/")
889
      .with_status(200)
890
      .with_header("Content-Type", "application/json")
891
      .with_header("Last-Modified", test_date)
892
      .with_body(test_json)
893
      .create();
894

895
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
896
    let temp_dir = tempdir().unwrap();
897
    let temp_dir_str = temp_dir.path().to_str().unwrap();
898

899
    let rt = Runtime::new().unwrap();
900
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
901

902
    assert!(result.is_ok());
903
    if let Ok(packages) = result {
904
      assert_eq!(packages.len(), 1);
905
      assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
906

907
      assert!(last_modified_path(temp_dir_str).exists());
908
      assert!(api_manifest_path(temp_dir_str).exists());
909

910
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
911
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
912
    }
913

914
    mock.assert();
915
  }
916

917
  #[test]
918
  fn test_download_file() {
919
    let mut server = Server::new();
920
    let temp_dir = tempdir().unwrap();
921
    let temp_dir_str = temp_dir.path().to_str().unwrap();
922

923
    let test_data = b"This is test file content";
924
    let content_length = test_data.len();
925

926
    let _mock = server
927
      .mock("GET", "/test-file.zip")
928
      .with_status(200)
929
      .with_header("Content-Type", "application/zip")
930
      .with_header("Content-Length", &content_length.to_string())
931
      .with_body(test_data)
932
      .create();
933

934
    let file_url = format!("{}/test-file.zip", server.url());
935
    let filename = "test-file.zip".to_string();
936

937
    let multi_progress = MultiProgress::new();
938
    let style_template = "";
939
    let progress_style = ProgressStyle::with_template(style_template)
940
      .unwrap()
941
      .progress_chars("#>-");
942

943
    let rt = Runtime::new().unwrap();
944
    let client = rt.block_on(async {
945
      reqwest::Client::builder()
946
        .timeout(Duration::from_secs(60))
947
        .build()
948
        .unwrap()
949
    });
950

951
    let result = rt.block_on(download_file(
952
      client.clone(),
953
      &file_url,
954
      &filename,
955
      multi_progress.clone(),
956
      progress_style.clone(),
957
      temp_dir_str,
958
    ));
959

960
    assert!(result.is_ok());
961

962
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
963
    assert!(downloads_dir.exists());
964

965
    let downloaded_file = downloads_dir.join(&filename);
966
    assert!(downloaded_file.exists());
967

968
    let file_content = std::fs::read(&downloaded_file).unwrap();
969
    assert_eq!(file_content, test_data);
970

971
    let result2 = rt.block_on(download_file(
972
      client.clone(),
973
      &file_url,
974
      &filename,
975
      multi_progress.clone(),
976
      progress_style.clone(),
977
      temp_dir_str,
978
    ));
979

980
    assert!(result2.is_ok());
981
  }
982

983
  #[test]
984
  fn test_download_file_missing_header() {
985
    let mut server = Server::new();
986
    let temp_dir = tempdir().unwrap();
987
    let temp_dir_str = temp_dir.path().to_str().unwrap();
988

989
    let test_data = b"This is test file content";
990

991
    let mock = server
992
      .mock("GET", "/test-file.zip")
993
      .with_status(200)
994
      .with_header("Content-Type", "application/zip")
995
      .with_body(test_data)
996
      .create();
997

998
    let file_url = format!("{}/test-file.zip", server.url());
999
    let filename = "test-file-no-header.zip".to_string();
1000

1001
    let multi_progress = MultiProgress::new();
1002
    let style_template = "";
1003
    let progress_style = ProgressStyle::with_template(style_template)
1004
      .unwrap()
1005
      .progress_chars("#>-");
1006

1007
    let rt = Runtime::new().unwrap();
1008
    let client = rt.block_on(async {
1009
      reqwest::Client::builder()
1010
        .timeout(Duration::from_secs(60))
1011
        .build()
1012
        .unwrap()
1013
    });
1014

1015
    let _result = rt.block_on(download_file(
1016
      client.clone(),
1017
      &file_url,
1018
      &filename,
1019
      multi_progress.clone(),
1020
      progress_style.clone(),
1021
      temp_dir_str,
1022
    ));
1023

1024
    mock.assert();
1025
  }
1026

1027
  #[test]
1028
  fn test_download_files() {
1029
    let mut server = Server::new();
1030
    let temp_dir = tempdir().unwrap();
1031
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1032

1033
    let test_data1 = b"This is test file 1 content";
1034
    let content_length1 = test_data1.len();
1035

1036
    let test_data2 = b"This is test file 2 content - longer content";
1037
    let content_length2 = test_data2.len();
1038

1039
    let mock1 = server
1040
      .mock("GET", "/file1.zip")
1041
      .with_status(200)
1042
      .with_header("Content-Type", "application/zip")
1043
      .with_header("Content-Length", &content_length1.to_string())
1044
      .with_body(test_data1)
1045
      .create();
1046

1047
    let mock2 = server
1048
      .mock("GET", "/file2.zip")
1049
      .with_status(200)
1050
      .with_header("Content-Type", "application/zip")
1051
      .with_header("Content-Length", &content_length2.to_string())
1052
      .with_body(test_data2)
1053
      .create();
1054

1055
    let mut urls = HashMap::new();
1056
    urls.insert(
1057
      "file1.zip".to_string(),
1058
      format!("{}/file1.zip", server.url()),
1059
    );
1060
    urls.insert(
1061
      "file2.zip".to_string(),
1062
      format!("{}/file2.zip", server.url()),
1063
    );
1064

1065
    let rt = Runtime::new().unwrap();
1066
    let result = rt.block_on(download_files(urls, temp_dir_str));
1067

1068
    assert!(result.is_ok());
1069

1070
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1071
    assert!(downloads_dir.exists());
1072

1073
    let file1 = downloads_dir.join("file1.zip");
1074
    let file2 = downloads_dir.join("file2.zip");
1075
    assert!(file1.exists());
1076
    assert!(file2.exists());
1077

1078
    let file1_content = std::fs::read(&file1).unwrap();
1079
    let file2_content = std::fs::read(&file2).unwrap();
1080
    assert_eq!(file1_content, test_data1);
1081
    assert_eq!(file2_content, test_data2);
1082

1083
    mock1.assert();
1084
    mock2.assert();
1085
  }
1086

1087
  #[test]
1088
  fn test_download_files_empty() {
1089
    let temp_dir = tempdir().unwrap();
1090
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1091

1092
    let urls = HashMap::new();
1093

1094
    let rt = Runtime::new().unwrap();
1095
    let result = rt.block_on(download_files(urls, temp_dir_str));
1096

1097
    assert!(result.is_ok());
1098
  }
1099
}
1100

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc