Empty API responses (200 + empty body) mean "known car, no times
submitted yet" on the records site — typically newly released prize
cars. Previously each such car burned 3 retries with backoff and then
aborted the entire fetch; 27 cars in the current DB hit this.
- Add AppError::EmptyResponse { url } as a distinct, deterministic
failure class; get_file fails immediately instead of retrying
- fetch_car downgrades EmptyResponse to a skip with an info log, so
fetch completes; the next fetch picks the cars up automatically
once records exist
- parse_car_times treats empty (or whitespace-only) timing files
like missing files, eliminating "EOF while parsing a value at line
1 column 0" parse errors from stale empty files
trash.csv Config column now lists only the copies to delete: for a
car whose one tune is a final build not best on any track (e.g.
owned 332 next to a best-on-213-tracks 323), only the dead tune is
shown instead of joining all owned configs. Categories 1 and 3 still
list all configs since the whole car goes.
Tests: empty response makes exactly 1 request and yields the
EmptyResponse variant; empty file parses Ok; partial-copy trash
shows only the deletable config.
125 lines
4.6 KiB
Rust
125 lines
4.6 KiB
Rust
//! Tests for the HTTP file download utility with mockito mock server.
|
|
|
|
use std::fs;
|
|
|
|
use mockito::{Mock, Server, ServerGuard};
|
|
use top_drives::get_file;
|
|
|
|
/// Helper: create a mock that returns a successful response with the given body.
|
|
fn mock_success(server: &mut ServerGuard, body: &str) -> Mock {
|
|
server.mock("GET", "/data.json").with_status(200).with_body(body).create()
|
|
}
|
|
|
|
/// Helper: create a mock that returns the given HTTP status.
|
|
fn mock_status(server: &mut ServerGuard, status: usize) -> Mock {
|
|
server.mock("GET", "/data.json").with_status(status).create()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_success() {
|
|
let mut server = Server::new_async().await;
|
|
let m = mock_success(&mut server, "hello world");
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
get_file(&url, out_path.to_str().unwrap()).await.unwrap();
|
|
|
|
assert_eq!(fs::read_to_string(&out_path).unwrap(), "hello world");
|
|
m.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_empty_response_fails_immediately_without_retry() {
|
|
let mut server = Server::new_async().await;
|
|
// 200 with empty body means "no data available yet" — permanent, so exactly
|
|
// one request must be made (no retries) and a distinguishable error returned.
|
|
let m = server.mock("GET", "/data.json").with_status(200).with_body("").expect(1).create();
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
|
let err = result.expect_err("Empty response should fail immediately");
|
|
assert!(
|
|
matches!(err, top_drives::AppError::EmptyResponse { .. }),
|
|
"Empty response should yield AppError::EmptyResponse, got: {err}"
|
|
);
|
|
assert!(!out_path.exists(), "No file should be written for an empty response");
|
|
m.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_server_error_retries() {
|
|
let mut server = Server::new_async().await;
|
|
// Return 500 three times, then 200
|
|
let m_fail = server.mock("GET", "/data.json").with_status(500).expect(3).create();
|
|
let m_ok = mock_success(&mut server, "recovered");
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
get_file(&url, out_path.to_str().unwrap()).await.unwrap();
|
|
|
|
assert_eq!(fs::read_to_string(&out_path).unwrap(), "recovered");
|
|
m_fail.assert();
|
|
m_ok.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_client_error_fails_immediately() {
|
|
let mut server = Server::new_async().await;
|
|
// 404 should fail immediately without retries
|
|
let m = mock_status(&mut server, 404).expect(1).create();
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
|
assert!(result.is_err(), "404 should fail immediately");
|
|
let err_msg = format!("{}", result.unwrap_err());
|
|
assert!(err_msg.contains("404"), "Error should mention HTTP 404: {err_msg}");
|
|
m.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_persistent_server_error_fails() {
|
|
let mut server = Server::new_async().await;
|
|
// 503 every time — should retry 3 times then fail
|
|
let m = server
|
|
.mock("GET", "/data.json")
|
|
.with_status(503)
|
|
.expect(4) // 1 initial + 3 retries
|
|
.create();
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
|
assert!(result.is_err(), "Persistent 503 should fail after retries");
|
|
let err_msg = format!("{}", result.unwrap_err());
|
|
assert!(err_msg.contains("503"), "Error should mention HTTP 503: {err_msg}");
|
|
m.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_atomic_write_no_partial_on_error() {
|
|
let mut server = Server::new_async().await;
|
|
// Return 404 — immediate failure, no file written
|
|
let m = mock_status(&mut server, 404).create();
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let out_path = dir.path().join("output.txt");
|
|
let url = format!("{}/data.json", server.url());
|
|
|
|
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
|
assert!(result.is_err());
|
|
// The output file should NOT exist (atomic write via temp + rename)
|
|
assert!(!out_path.exists(), "No partial file should be left on failure");
|
|
m.assert();
|
|
}
|