use std::{fs, time::Duration};
use rs_zero::core::logging::{
ErrorAggregator, LogConfig, LogFields, LogFormat, LogSampler, LogSpanEvents, LogWriterConfig,
RedactionConfig, RollingFileConfig, SamplingConfig, redact_text, validate_writer,
with_scoped_tracing,
};
#[test]
fn log_config_supports_structured_json_and_fields() {
let config = LogConfig::default()
.with_format(LogFormat::Json)
.with_service("billing")
.with_writer(LogWriterConfig::Stdout);
assert_eq!(config.format, LogFormat::Json);
assert_eq!(config.service.as_deref(), Some("billing"));
assert_eq!(config.filter, "info");
}
#[test]
fn log_fields_preserve_low_cardinality_context() {
let fields = LogFields::new("billing")
.with_transport("grpc")
.with_route("/users/:id")
.with_request_id("req-1")
.with_trace_id("4bf92f3577b34da6a3ce929d0e0e4736")
.with_span_id("00f067aa0ba902b7")
.with_status("Ok");
let labels = fields.as_pairs();
assert!(labels.contains(&("service".to_string(), "billing".to_string())));
assert!(labels.contains(&(
"trace_id".to_string(),
"4bf92f3577b34da6a3ce929d0e0e4736".to_string()
)));
assert!(!labels.iter().any(|(_, value)| value == "/users/42"));
}
#[test]
fn redaction_masks_sensitive_fields_and_values() {
let config = RedactionConfig::default();
let redacted = redact_text(
"Authorization: Bearer abc.def password=my-secret api_key=12345",
&config,
);
assert!(!redacted.contains("abc.def"));
assert!(!redacted.contains("my-secret"));
assert!(!redacted.contains("12345"));
assert!(redacted.contains("[REDACTED]"));
}
#[test]
fn sampler_limits_repeated_logs_by_key() {
let sampler = LogSampler::new(SamplingConfig {
enabled: true,
first_n: 1,
thereafter: 3,
});
assert!(sampler.should_log("db-timeout"));
assert!(!sampler.should_log("db-timeout"));
assert!(!sampler.should_log("db-timeout"));
assert!(sampler.should_log("db-timeout"));
}
#[test]
fn error_aggregator_groups_similar_errors() {
let aggregator = ErrorAggregator::new();
aggregator.record("user 42 failed with token abc123");
aggregator.record("user 99 failed with token def456");
let snapshot = aggregator.snapshot();
assert_eq!(snapshot.total, 2);
assert_eq!(snapshot.groups.len(), 1);
}
#[test]
fn rolling_file_writer_validates_path_and_rotation() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("service.log");
let writer = LogWriterConfig::RollingFile(RollingFileConfig {
path: path.clone(),
max_bytes: Some(128),
max_files: 2,
max_age: Some(Duration::from_secs(60)),
});
let prepared = validate_writer(&writer).expect("writer");
assert_eq!(prepared.path.as_deref(), Some(path.as_path()));
assert!(prepared.rotation_enabled);
}
#[test]
fn subscriber_redacts_text_and_json_outputs_before_write() {
for format in [LogFormat::Text, LogFormat::Json] {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join(format!("{format:?}.log"));
let config = LogConfig::default()
.with_format(format)
.with_writer(LogWriterConfig::file(&path))
.with_redaction(RedactionConfig::default());
with_scoped_tracing(config, || {
let span = tracing::info_span!(
"auth_flow",
authorization = "Bearer raw-span-token",
cookie = "session=raw-cookie"
);
let _entered = span.enter();
tracing::info!(
token = "raw-event-token",
password = "raw-password",
api_key = "raw-api-key",
error = "secret raw-error-secret",
"Authorization: Bearer raw-message-secret"
);
})
.expect("scoped tracing");
let output = fs::read_to_string(&path).expect("log output");
assert!(output.contains("[REDACTED]"));
for raw in [
"raw-span-token",
"raw-cookie",
"raw-event-token",
"raw-password",
"raw-api-key",
"raw-error-secret",
"raw-message-secret",
] {
assert!(
!output.contains(raw),
"{raw} leaked in {format:?}: {output}"
);
}
if format == LogFormat::Json {
for line in output.lines().filter(|line| !line.trim().is_empty()) {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|error| panic!("invalid JSON log line: {error}: {line}"));
}
}
}
}
#[test]
fn rolling_file_writer_rotates_by_size_at_runtime() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("service.log");
let config = LogConfig {
filter: "info".to_string(),
ansi: false,
span_events: LogSpanEvents::None,
writer: LogWriterConfig::RollingFile(RollingFileConfig {
path: path.clone(),
max_bytes: Some(256),
max_files: 2,
max_age: None,
}),
..LogConfig::default()
};
with_scoped_tracing(config, || {
for index in 0..64 {
tracing::info!(
index,
payload = "runtime rotation payload with enough bytes",
"rotation-check"
);
}
})
.expect("scoped tracing");
let rotated_one = path.with_file_name("service.log.1");
let rotated_two = path.with_file_name("service.log.2");
assert!(path.exists(), "active log file should exist");
assert!(rotated_one.exists(), "first rotated file should exist");
assert!(
rotated_two.exists(),
"second rotated file should be retained"
);
assert!(
!path.with_file_name("service.log.3").exists(),
"max_files should cap retained rotated files"
);
}