#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashSet;
use proptest::prelude::*;
use themql_core::{Error, ErrorCode};
const CONTRACT: &[(ErrorCode, &str)] = &[
(ErrorCode::TransportError, "transport_error"),
(ErrorCode::ResolverError, "resolver_error"),
(ErrorCode::CacheMiss, "cache_miss"),
(ErrorCode::NotFound, "not_found"),
(ErrorCode::ValidationError, "validation_error"),
(ErrorCode::AuthorizationError, "authorization_error"),
(ErrorCode::Timeout, "timeout"),
(ErrorCode::Conflict, "conflict"),
(ErrorCode::InternalError, "internal_error"),
];
fn all_codes() -> Vec<ErrorCode> {
CONTRACT.iter().map(|(code, _)| *code).collect()
}
#[test]
fn every_code_renders_its_declared_wire_string() {
for (code, expected) in CONTRACT {
assert_eq!(
code.to_string(),
*expected,
"{code:?} must render as {expected}; a rename here is a wire break"
);
}
}
#[test]
fn every_code_serde_round_trips_through_its_wire_string() {
for (code, wire) in CONTRACT {
let json = serde_json::to_string(code).expect("ErrorCode serializes");
assert_eq!(
json,
format!("\"{wire}\""),
"{code:?} must serialise to its declared wire string"
);
let back: ErrorCode = serde_json::from_str(&json).expect("ErrorCode deserializes");
assert_eq!(back, *code, "{code:?} must survive a serde round trip");
}
}
#[test]
fn wire_strings_are_injective_across_every_code() {
let mut seen: HashSet<&str> = HashSet::new();
for (code, wire) in CONTRACT {
assert!(
seen.insert(wire),
"{code:?} reuses the wire string {wire:?}; the receiving side could \
not tell the two outcomes apart"
);
}
assert_eq!(seen.len(), CONTRACT.len());
}
#[test]
fn authorization_denial_is_distinct_from_every_other_code() {
let denial = Error::authorization_error("grant revoked");
assert_eq!(denial.code, ErrorCode::AuthorizationError);
for other in all_codes() {
if other == ErrorCode::AuthorizationError {
continue;
}
assert_ne!(
denial.code, other,
"an authorization denial must not be reportable as {other:?}"
);
}
assert_ne!(denial.code, ErrorCode::CacheMiss);
assert_ne!(denial.code, ErrorCode::NotFound);
}
#[test]
fn not_found_and_cache_miss_drive_opposite_behaviour_and_stay_separate() {
assert_ne!(
Error::not_found("no such asset").code,
Error::cache_miss("no resolver").code
);
}
#[test]
fn conflict_is_distinct_from_validation_error() {
assert_ne!(
Error::conflict("cas lost").code,
Error::validation_error("malformed").code
);
}
#[test]
fn constructors_set_their_own_code_and_preserve_the_message() {
let cases = [
(Error::not_found("m"), ErrorCode::NotFound),
(
Error::authorization_error("m"),
ErrorCode::AuthorizationError,
),
(Error::conflict("m"), ErrorCode::Conflict),
];
for (err, expected) in cases {
assert_eq!(err.code, expected);
assert_eq!(err.message, "m", "the message must survive construction");
}
}
#[test]
fn builders_preserve_the_authorization_code() {
let err =
Error::authorization_error("denied").with_details(serde_json::json!({"role": "viewer"}));
assert_eq!(err.code, ErrorCode::AuthorizationError);
assert!(err.details.is_some(), "details must be retained");
}
proptest! {
#[test]
fn wire_string_is_always_lowercase_snake_case(code in prop::sample::select(
all_codes()
)) {
let rendered = code.to_string();
prop_assert!(!rendered.is_empty());
prop_assert!(rendered.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
prop_assert!(rendered.chars().next().is_some_and(|c| c.is_ascii_lowercase()));
}
#[test]
fn every_code_json_round_trips(code in prop::sample::select(all_codes())) {
let json = serde_json::to_string(&code).expect("ErrorCode serializes");
let back: ErrorCode = serde_json::from_str(&json).expect("ErrorCode deserializes");
prop_assert_eq!(back, code);
}
}