#![allow(clippy::unwrap_used, clippy::panic)] use serde_json::json;
use super::*;
fn make_store(ttl_secs: u64) -> InMemoryIdempotencyStore {
InMemoryIdempotencyStore::new(Duration::from_secs(ttl_secs), 100)
}
fn scope() -> IdempotencyScope {
IdempotencyScope {
tenant: Some("tenant-a".to_string()),
principal: Some("alice".to_string()),
method: "POST".to_string(),
path: "/users".to_string(),
}
}
fn k(client_key: &str) -> ScopedIdempotencyKey {
scope().key(client_key)
}
fn make_response() -> StoredResponse {
StoredResponse {
status: 201,
headers: vec![("x-request-id".to_string(), "abc".to_string())],
body: Some(json!({"id": 1, "name": "Alice"})),
}
}
#[tokio::test]
async fn new_key_returns_new() {
let store = make_store(3600);
let body_hash = hash_body(&json!({"name": "Alice"}));
assert!(matches!(store.check(&k("key1"), body_hash).await, IdempotencyCheck::New));
}
#[tokio::test]
async fn stored_key_replays_response() {
let store = make_store(3600);
let body = json!({"name": "Alice"});
let body_hash = hash_body(&body);
let response = make_response();
store.store(k("key1"), body_hash, response).await;
match store.check(&k("key1"), body_hash).await {
IdempotencyCheck::Replay(stored) => {
assert_eq!(stored.status, 201);
assert_eq!(stored.body.as_ref().unwrap()["name"], "Alice");
},
other => panic!("Expected Replay, got {other:?}"),
}
}
#[tokio::test]
async fn same_key_different_body_returns_conflict() {
let store = make_store(3600);
let body1 = json!({"name": "Alice"});
let body2 = json!({"name": "Bob"});
let hash1 = hash_body(&body1);
let hash2 = hash_body(&body2);
store.store(k("key1"), hash1, make_response()).await;
assert!(matches!(store.check(&k("key1"), hash2).await, IdempotencyCheck::Conflict));
}
#[tokio::test(start_paused = true)]
async fn expired_key_treated_as_new() {
let store = InMemoryIdempotencyStore::new(Duration::from_millis(1), 100);
let body = json!({"name": "Alice"});
let body_hash = hash_body(&body);
store.store(k("key1"), body_hash, make_response()).await;
tokio::time::advance(Duration::from_millis(5)).await;
assert!(matches!(store.check(&k("key1"), body_hash).await, IdempotencyCheck::New));
}
#[tokio::test(start_paused = true)]
async fn max_entries_evicts_oldest() {
let store = InMemoryIdempotencyStore::new(Duration::from_hours(1), 3);
let hash = hash_body(&json!({}));
store.store(k("key1"), hash, make_response()).await;
tokio::time::advance(Duration::from_millis(1)).await;
store.store(k("key2"), hash, make_response()).await;
tokio::time::advance(Duration::from_millis(1)).await;
store.store(k("key3"), hash, make_response()).await;
tokio::time::advance(Duration::from_millis(1)).await;
store.store(k("key4"), hash, make_response()).await;
assert!(matches!(store.check(&k("key1"), hash).await, IdempotencyCheck::New));
assert!(matches!(store.check(&k("key2"), hash).await, IdempotencyCheck::Replay(_)));
}
#[test]
fn body_hash_deterministic() {
let body = json!({"name": "Alice", "age": 30});
let hash1 = hash_body(&body);
let hash2 = hash_body(&body);
assert_eq!(hash1, hash2);
}
#[test]
fn body_hash_different_for_different_bodies() {
let hash1 = hash_body(&json!({"name": "Alice"}));
let hash2 = hash_body(&json!({"name": "Bob"}));
assert_ne!(hash1, hash2);
}
#[tokio::test]
async fn create_store_returns_arc() {
let store = create_store(3600);
let body_hash = hash_body(&json!({}));
assert!(matches!(store.check(&k("key1"), body_hash).await, IdempotencyCheck::New));
}
#[test]
fn body_hash_is_insensitive_to_key_order() {
let a = hash_body(&json!({"name": "Alice", "email": "a@x"}));
let b = hash_body(&json!({"email": "a@x", "name": "Alice"}));
assert_eq!(a, b, "the same request in a different key order must hash the same");
}
#[test]
fn body_hash_is_insensitive_to_nested_key_order() {
let a = hash_body(&json!({"outer": {"x": 1, "y": [{"p": 1, "q": 2}]}}));
let b = hash_body(&json!({"outer": {"y": [{"q": 2, "p": 1}], "x": 1}}));
assert_eq!(a, b, "nested objects must normalize at every level");
}
#[test]
fn body_hash_still_separates_different_values() {
assert_ne!(
hash_body(&json!({"a": 1, "b": 2})),
hash_body(&json!({"a": 2, "b": 1})),
"swapping values is a different request and must hash differently"
);
}
#[test]
fn body_hash_respects_array_order() {
assert_ne!(
hash_body(&json!({"items": [1, 2]})),
hash_body(&json!({"items": [2, 1]})),
"arrays are ordered; normalizing them would merge distinct requests"
);
}
#[tokio::test]
async fn the_same_client_key_does_not_cross_resources() {
let store = make_store(3600);
let body = json!({});
let hash = hash_body(&body);
let users = IdempotencyScope {
tenant: Some("tenant-a".to_string()),
principal: None,
method: "POST".to_string(),
path: "/users".to_string(),
};
let orders = IdempotencyScope {
path: "/orders".to_string(),
..users.clone()
};
store.store(users.key("op-1"), hash, make_response()).await;
assert!(
matches!(store.check(&orders.key("op-1"), hash).await, IdempotencyCheck::New),
"a key stored against /users must not replay for /orders"
);
assert!(
matches!(store.check(&users.key("op-1"), hash).await, IdempotencyCheck::Replay(_)),
"the key must still replay within its own scope"
);
}
#[tokio::test]
async fn the_same_client_key_does_not_cross_tenants() {
let store = make_store(3600);
let hash = hash_body(&json!({"sku": "X", "qty": 1}));
let a = IdempotencyScope {
tenant: Some("tenant-a".to_string()),
principal: None,
method: "POST".to_string(),
path: "/orders".to_string(),
};
let b = IdempotencyScope {
tenant: Some("tenant-b".to_string()),
..a.clone()
};
store.store(a.key("order-42"), hash, make_response()).await;
assert!(
matches!(store.check(&b.key("order-42"), hash).await, IdempotencyCheck::New),
"tenant B must not receive tenant A's stored response"
);
}
#[tokio::test]
async fn the_same_client_key_does_not_cross_methods() {
let store = make_store(3600);
let hash = hash_body(&json!({}));
let post = IdempotencyScope {
tenant: None,
principal: None,
method: "POST".to_string(),
path: "/users".to_string(),
};
let put = IdempotencyScope {
method: "PUT".to_string(),
..post.clone()
};
store.store(post.key("k"), hash, make_response()).await;
assert!(matches!(store.check(&put.key("k"), hash).await, IdempotencyCheck::New));
}
#[test]
fn scope_segments_cannot_be_forged_into_one_another() {
let a = IdempotencyScope {
tenant: Some("ab".to_string()),
principal: None,
method: "POST".to_string(),
path: "/x".to_string(),
};
let b = IdempotencyScope {
tenant: Some("a".to_string()),
principal: None,
method: "bPOST".to_string(),
path: "/x".to_string(),
};
assert_ne!(a.key("k").as_str(), b.key("k").as_str());
let c = IdempotencyScope {
tenant: Some("a".to_string()),
principal: Some("b".to_string()),
method: "POST".to_string(),
path: "/x".to_string(),
};
let d = IdempotencyScope {
tenant: Some("a@b".to_string()),
principal: None,
..c.clone()
};
assert_ne!(c.key("k").as_str(), d.key("k").as_str());
let empty = IdempotencyScope {
principal: Some(String::new()),
..d.clone()
};
let anonymous = IdempotencyScope {
principal: None,
..d
};
assert_ne!(empty.key("k").as_str(), anonymous.key("k").as_str());
}
#[tokio::test]
async fn the_same_client_key_does_not_cross_principals() {
let store = make_store(3600);
let hash = hash_body(&json!({"sku": "X", "qty": 1}));
let alice = scope();
let bob = IdempotencyScope {
principal: Some("bob".to_string()),
..scope()
};
let anonymous = IdempotencyScope {
principal: None,
..scope()
};
store.store(alice.key("order-42"), hash, make_response()).await;
assert!(
matches!(store.check(&bob.key("order-42"), hash).await, IdempotencyCheck::New),
"bob must not receive alice's stored response"
);
assert!(
matches!(store.check(&anonymous.key("order-42"), hash).await, IdempotencyCheck::New),
"nor an anonymous caller"
);
assert!(
matches!(store.check(&alice.key("order-42"), hash).await, IdempotencyCheck::Replay(_)),
"alice's retry still replays"
);
}