Files
galaxy/crates/simple_logger/src/lib_tests.rs
T

259 lines
9.7 KiB
Rust

//! Tests for the size-based rotation helpers in `lib.rs`.
//!
//! The high-level `SimpleLogger::new` path runs in a background executor and
//! interleaves async file I/O with channel reads; integration coverage at that
//! layer is provided by [`crate::manager`]'s tests. The cases here exercise the
//! pure file-shuffling helpers (`perform_rotation`, `path_with_suffix`) and the
//! `RotationConfig` constructor, all of which are deterministic and don't
//! require an executor — keeping the unit tests fast and avoiding the
//! flakiness that comes with background-task synchronization.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{path_with_suffix, perform_rotation, RotationConfig};
/// Unique temp directory per test, so parallel cargo nextest runs don't
/// collide on shared state.
fn temp_dir(name: &str) -> PathBuf {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"simple-logger-rotation-{name}-{}-{id}",
std::process::id()
));
fs::create_dir_all(&dir).expect("failed to create test temp dir");
dir
}
fn write_file(path: &Path, contents: &[u8]) {
fs::write(path, contents).expect("failed to write test fixture file");
}
fn read_to_string(path: &Path) -> String {
fs::read_to_string(path).expect("failed to read file")
}
// ---------- RotationConfig ----------
#[test]
fn rotation_config_zero_max_size_disables() {
assert!(RotationConfig::new(0, 5).is_none());
}
#[test]
fn rotation_config_zero_max_rotation_disables() {
assert!(RotationConfig::new(10 * 1024 * 1024, 0).is_none());
}
#[test]
fn rotation_config_both_zero_disables() {
assert!(RotationConfig::new(0, 0).is_none());
}
#[test]
fn rotation_config_positive_values_construct() {
let c = RotationConfig::new(1024, 5).expect("positive values should construct");
assert_eq!(c.max_file_size_bytes(), 1024);
assert_eq!(c.max_rotation(), 5);
}
// ---------- path_with_suffix ----------
#[test]
fn path_with_suffix_appends_dot_n_without_replacing_extension() {
let base = Path::new("/tmp/foo/bar.log");
let p = path_with_suffix(base, 3);
assert_eq!(p, PathBuf::from("/tmp/foo/bar.log.3"));
}
#[test]
fn path_with_suffix_preserves_compound_extensions() {
let base = Path::new("/tmp/foo/server.stderr.log");
let p = path_with_suffix(base, 1);
// Crucially this must be `server.stderr.log.1` — not `server.stderr.1`
// (which is what `set_extension` would produce).
assert_eq!(p, PathBuf::from("/tmp/foo/server.stderr.log.1"));
}
#[test]
fn path_with_suffix_handles_no_extension() {
let base = Path::new("/tmp/foo/logfile");
let p = path_with_suffix(base, 7);
assert_eq!(p, PathBuf::from("/tmp/foo/logfile.7"));
}
// ---------- perform_rotation: file-level behavior ----------
/// The base case: there's only an active file at `base_path`. After rotation,
/// the active file should be gone (renamed to `.1`) and no other files should
/// exist.
#[tokio::test]
async fn rotate_promotes_active_file_to_dot_one_when_no_prior_rotations() {
let dir = temp_dir("first-rotation");
let base = dir.join("server.log");
write_file(&base, b"hello\n");
perform_rotation(&base, 5)
.await
.expect("rotation should succeed");
assert!(!base.exists(), "active file must be gone after rotation");
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "hello\n");
assert!(!path_with_suffix(&base, 2).exists());
}
/// With one prior rotated file, the active becomes `.1` and the existing `.1`
/// shifts to `.2`. Verifies the iteration goes from oldest to youngest (so we
/// don't clobber an unmoved file).
#[tokio::test]
async fn rotate_shifts_prior_rotations_up_by_one() {
let dir = temp_dir("shift");
let base = dir.join("srv.log");
write_file(&base, b"current\n");
write_file(&path_with_suffix(&base, 1), b"previous\n");
perform_rotation(&base, 5)
.await
.expect("rotation should succeed");
assert!(!base.exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "previous\n");
assert!(!path_with_suffix(&base, 3).exists());
}
/// At the rotation cap, the oldest rotated file (`.max_rotation`) is deleted
/// before the shift, so no file ages past `.max_rotation`. This is the
/// "automatic cleanup" property the bug-fix is meant to provide.
#[tokio::test]
async fn rotate_discards_oldest_file_when_at_cap() {
let dir = temp_dir("cap");
let base = dir.join("server.log");
write_file(&base, b"current\n");
for n in 1..=3 {
write_file(
&path_with_suffix(&base, n),
format!("rotated-{n}\n").as_bytes(),
);
}
perform_rotation(&base, 3)
.await
.expect("rotation should succeed");
// `.3` was the oldest and is gone (its contents — "rotated-3" — are not
// preserved anywhere). `.1` became `.2`, `.2` became `.3`, and the
// original active file is at `.1`.
assert!(!base.exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "rotated-1\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "rotated-2\n");
assert!(!path_with_suffix(&base, 4).exists());
}
/// With `max_rotation = 1`, the only rotated slot is `.1`. Each rotation
/// overwrites `.1` with the latest active contents and discards the prior
/// `.1`. Verifies the minimum-rotation edge case doesn't off-by-one.
#[tokio::test]
async fn rotate_with_max_rotation_one_overwrites_dot_one() {
let dir = temp_dir("max-one");
let base = dir.join("server.log");
write_file(&base, b"second\n");
write_file(&path_with_suffix(&base, 1), b"first\n");
perform_rotation(&base, 1)
.await
.expect("rotation should succeed");
assert!(!base.exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "second\n");
// The previous `.1` ("first") has been displaced and discarded; the cap is
// `max_rotation = 1`, so there's no `.2`.
assert!(!path_with_suffix(&base, 2).exists());
}
/// Missing intermediate rotated files (e.g. the user hasn't accumulated enough
/// rotations to fill every slot) must not cause the rotation to error. Only the
/// rename of the active file is fatal.
#[tokio::test]
async fn rotate_tolerates_missing_intermediate_files() {
let dir = temp_dir("sparse");
let base = dir.join("server.log");
write_file(&base, b"current\n");
// Skip `.1`, `.2`, `.3`; only `.4` exists.
write_file(&path_with_suffix(&base, 4), b"old\n");
perform_rotation(&base, 5)
.await
.expect("rotation should succeed despite gaps");
assert!(!base.exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
// `.4` shifts to `.5`. `.2`, `.3`, `.4` remain empty/missing.
assert!(!path_with_suffix(&base, 2).exists());
assert!(!path_with_suffix(&base, 3).exists());
assert!(!path_with_suffix(&base, 4).exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 5)), "old\n");
}
/// Rotation when the active file doesn't exist (e.g. caller invoked rotation
/// preemptively before any writes) should not error — there's simply nothing
/// to promote. Existing rotated files still shift.
#[tokio::test]
async fn rotate_no_op_when_active_file_missing_but_still_shifts_rotated() {
let dir = temp_dir("no-active");
let base = dir.join("server.log");
// No active file, but two rotated files exist.
write_file(&path_with_suffix(&base, 1), b"one\n");
write_file(&path_with_suffix(&base, 2), b"two\n");
perform_rotation(&base, 5)
.await
.expect("rotation should succeed with no active file");
assert!(!base.exists());
// Without an active file, `.1` is empty (the shift moved `.1` -> `.2` and
// `.2` -> `.3`; nothing populated `.1`).
assert!(!path_with_suffix(&base, 1).exists());
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "one\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "two\n");
}
/// After three consecutive rotations, the in-use file's contents propagate to
/// `.3` and the eldest contents from the first rotation are gone. This
/// exercises the iteration-direction property: a left-to-right loop would
/// clobber files mid-shift.
#[tokio::test]
async fn three_consecutive_rotations_preserve_order_and_discard_oldest() {
let dir = temp_dir("three-rotations");
let base = dir.join("server.log");
write_file(&base, b"v1\n");
perform_rotation(&base, 3).await.unwrap();
// After rotation 1: .1 = v1
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v1\n");
write_file(&base, b"v2\n");
perform_rotation(&base, 3).await.unwrap();
// After rotation 2: .1 = v2, .2 = v1
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v2\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v1\n");
write_file(&base, b"v3\n");
perform_rotation(&base, 3).await.unwrap();
// After rotation 3: .1 = v3, .2 = v2, .3 = v1
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v3\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v2\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "v1\n");
write_file(&base, b"v4\n");
perform_rotation(&base, 3).await.unwrap();
// After rotation 4: v1 is discarded; .1=v4, .2=v3, .3=v2
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v4\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v3\n");
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "v2\n");
assert!(!path_with_suffix(&base, 4).exists());
}