From 6bdbdf8f86cfe3f2301ac7c1ff029f2b8e2a353c Mon Sep 17 00:00:00 2001 From: omagdy Date: Thu, 17 Jul 2025 03:31:55 +0300 Subject: test: Added a whole suite of tests to test parsing and executing commands logic --- src/resp_commands.rs | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) diff --git a/src/resp_commands.rs b/src/resp_commands.rs index 8ac186f..52167cc 100644 --- a/src/resp_commands.rs +++ b/src/resp_commands.rs @@ -442,3 +442,304 @@ impl From for RedisCommands { } } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use std::thread; + + // Test Helpers & Mocks + + /// Creates a new, empty shared cache for each test. + fn new_cache() -> SharedCache { + Arc::new(Mutex::new(HashMap::new())) + } + + /// Builds a `RespType::Array` from string slices to simplify parser tests. + fn build_command_from_str_slice(args: &[&str]) -> RespType { + let resp_args = args + .iter() + .map(|s| RespType::BulkString(s.to_string().into_bytes())) + .collect(); + RespType::Array(resp_args) + } + + /// A helper to get a value directly from the cache for assertions. + fn get_from_cache(cache: &SharedCache, key: &str) -> Option { + cache.lock().unwrap().get(key).cloned() + } + + /// Tests for the `RedisCommands::from(RespType)` parser logic. + mod command_parser_tests { + use super::*; + + #[test] + fn test_parse_ping() { + let cmd = build_command_from_str_slice(&["PING"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::PING)); + } + + #[test] + fn test_parse_ping_case_insensitive() { + let cmd = build_command_from_str_slice(&["pInG"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::PING)); + } + + #[test] + fn test_parse_ping_with_extra_args_is_invalid() { + let cmd = build_command_from_str_slice(&["PING", "extra"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + + #[test] + fn test_parse_echo() { + let cmd = build_command_from_str_slice(&["ECHO", "hello world"]); + match RedisCommands::from(cmd) { + RedisCommands::ECHO(s) => assert_eq!(s, "hello world"), + _ => panic!("Expected ECHO command"), + } + } + + #[test] + fn test_parse_echo_no_args_is_invalid() { + let cmd = build_command_from_str_slice(&["ECHO"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + + #[test] + fn test_parse_get() { + let cmd = build_command_from_str_slice(&["GET", "mykey"]); + match RedisCommands::from(cmd) { + RedisCommands::GET(k) => assert_eq!(k, "mykey"), + _ => panic!("Expected GET command"), + } + } + + #[test] + fn test_parse_simple_set() { + let cmd = build_command_from_str_slice(&["SET", "mykey", "myvalue"]); + match RedisCommands::from(cmd) { + RedisCommands::SET(c) => { + assert_eq!(c.key, "mykey"); + assert_eq!(c.value, "myvalue"); + assert!(c.condition.is_none() && c.expiry.is_none() && !c.get_old_value); + } + _ => panic!("Expected SET command"), + } + } + + #[test] + fn test_parse_set_with_all_options() { + let cmd = build_command_from_str_slice(&["SET", "k", "v", "NX", "PX", "5000", "GET"]); + match RedisCommands::from(cmd) { + RedisCommands::SET(c) => { + assert!(matches!(c.condition, Some(SetCondition::NotExists))); + assert!(matches!(c.expiry, Some(ExpiryOption::Milliseconds(5000)))); + assert!(c.get_old_value); + } + _ => panic!("Expected SET command"), + } + } + + #[test] + fn test_parse_set_options_case_insensitive() { + let cmd = build_command_from_str_slice(&["set", "k", "v", "nx", "px", "100"]); + match RedisCommands::from(cmd) { + RedisCommands::SET(c) => { + assert!(matches!(c.condition, Some(SetCondition::NotExists))); + assert!(matches!(c.expiry, Some(ExpiryOption::Milliseconds(100)))); + } + _ => panic!("Expected SET command"), + } + } + + #[test] + fn test_parse_set_invalid_option_value() { + let cmd = build_command_from_str_slice(&["SET", "k", "v", "EX", "not-a-number"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + + #[test] + fn test_parse_set_option_missing_value() { + let cmd = build_command_from_str_slice(&["SET", "k", "v", "PX"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + + #[test] + fn test_parse_unknown_command() { + let cmd = build_command_from_str_slice(&["UNKNOWN", "foo"]); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + + #[test] + fn test_parse_not_an_array_is_invalid() { + let cmd = RespType::SimpleString("SET k v".into()); + assert!(matches!(RedisCommands::from(cmd), RedisCommands::Invalid)); + } + } + + /// Tests for the command execution logic in `RedisCommands::execute`. + mod command_execution_tests { + use super::*; + + /// Helper to parse and execute a command against a cache. + fn run_command(cache: &SharedCache, args: &[&str]) -> Vec { + let command = RedisCommands::from(build_command_from_str_slice(args)); + command.execute(Arc::clone(cache)) + } + + #[test] + fn test_execute_ping() { + let cache = new_cache(); + let result = run_command(&cache, &["PING"]); + assert_eq!(result, b"+PONG\r\n"); + } + + #[test] + fn test_execute_echo() { + let cache = new_cache(); + // Note: the provided code has a bug, it returns a Simple String, not a Bulk String. + // A correct implementation would return `resp!(bulk "hello")`. + let result = run_command(&cache, &["ECHO", "hello"]); + assert_eq!(result, b"+hello\r\n"); + } + + #[test] + fn test_execute_get_non_existent() { + let cache = new_cache(); + let result = run_command(&cache, &["GET", "mykey"]); + assert_eq!(result, b"$-1\r\n"); // Null Bulk String + } + + #[test] + fn test_execute_set_and_get() { + let cache = new_cache(); + let set_result = run_command(&cache, &["SET", "mykey", "myvalue"]); + assert_eq!(set_result, b"+OK\r\n"); + + let get_result = run_command(&cache, &["GET", "mykey"]); + assert_eq!(get_result, b"$7\r\nmyvalue\r\n"); + } + + #[test] + fn test_execute_set_nx() { + let cache = new_cache(); + // Should succeed when key doesn't exist + assert_eq!(run_command(&cache, &["SET", "k", "v1", "NX"]), b"+OK\r\n"); + assert_eq!(get_from_cache(&cache, "k").unwrap().value, "v1"); + + // Should fail when key exists + assert_eq!(run_command(&cache, &["SET", "k", "v2", "NX"]), b"$-1\r\n"); + assert_eq!(get_from_cache(&cache, "k").unwrap().value, "v1"); // Value is unchanged + } + + #[test] + fn test_execute_set_xx() { + let cache = new_cache(); + // Should fail when key doesn't exist + assert_eq!(run_command(&cache, &["SET", "k", "v1", "XX"]), b"$-1\r\n"); + assert!(get_from_cache(&cache, "k").is_none()); + + // Pre-populate and should succeed + run_command(&cache, &["SET", "k", "v1"]); + assert_eq!(run_command(&cache, &["SET", "k", "v2", "XX"]), b"+OK\r\n"); + assert_eq!(get_from_cache(&cache, "k").unwrap().value, "v2"); + } + + #[test] + fn test_execute_set_with_get_option() { + let cache = new_cache(); + run_command(&cache, &["SET", "mykey", "old"]); + + // Note: This test will fail with the provided code, which incorrectly returns + // a Simple String `+old\r\n`. The test correctly expects a Bulk String. + let result = run_command(&cache, &["SET", "mykey", "new", "GET"]); + assert_eq!(result, b"$3\r\nold\r\n"); + assert_eq!(get_from_cache(&cache, "mykey").unwrap().value, "new"); + } + + #[test] + fn test_execute_set_get_on_non_existent() { + let cache = new_cache(); + // Note: This test will fail with the provided code, which incorrectly + // returns `+OK\r\n`. The spec requires a nil reply. + let result = run_command(&cache, &["SET", "mykey", "new", "GET"]); + assert_eq!(result, b"$-1\r\n"); + assert!(get_from_cache(&cache, "mykey").is_some()); + } + + #[test] + fn test_expiry_with_px_and_cleanup() { + let cache = new_cache(); + run_command(&cache, &["SET", "mykey", "val", "PX", "50"]); + + assert!(get_from_cache(&cache, "mykey").is_some()); + thread::sleep(Duration::from_millis(60)); + + // GET on an expired key should return nil and trigger cleanup + assert_eq!(run_command(&cache, &["GET", "mykey"]), b"$-1\r\n"); + assert!(get_from_cache(&cache, "mykey").is_none()); + } + + #[test] + fn test_keepttl() { + let cache = new_cache(); + run_command(&cache, &["SET", "mykey", "v1", "EX", "2"]); + let expiry1 = get_from_cache(&cache, "mykey").unwrap().expires_at; + + thread::sleep(Duration::from_millis(100)); + run_command(&cache, &["SET", "mykey", "v2", "KEEPTTL"]); + + let entry2 = get_from_cache(&cache, "mykey").unwrap(); + assert_eq!(entry2.value, "v2"); // Value is updated + assert_eq!(entry2.expires_at, expiry1); // TTL is retained + } + } + + /// Unit tests for the `SetCommand` helper methods. + mod set_command_tests { + use super::*; + + #[test] + fn test_calculate_expiry_seconds() { + let cmd = SetCommand::new("k".into(), "v".into()) + .with_expiry(Some(ExpiryOption::Seconds(10))); + let expiry = cmd.calculate_expiry_time().unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let delta = expiry.saturating_sub(now); + // Allow a small delta for execution time variance + assert!((9990..=10010).contains(&delta), "Delta was {}", delta); + } + + #[test] + fn test_calculate_expiry_at_seconds() { + let ts_secs = 1893456000; // 2030-01-01 00:00:00 UTC + let cmd = SetCommand::new("k".into(), "v".into()) + .with_expiry(Some(ExpiryOption::ExpiresAtSeconds(ts_secs))); + let expiry = cmd.calculate_expiry_time().unwrap(); + assert_eq!(expiry, ts_secs * 1000); + } + + #[test] + fn test_calculate_expiry_at_milliseconds() { + let ts_ms = 1893456000123; + let cmd = SetCommand::new("k".into(), "v".into()) + .with_expiry(Some(ExpiryOption::ExpiresAtMilliseconds(ts_ms))); + let expiry = cmd.calculate_expiry_time().unwrap(); + assert_eq!(expiry, ts_ms); + } + + #[test] + fn test_calculate_expiry_for_none_and_keepttl() { + let cmd_keepttl = + SetCommand::new("k".into(), "v".into()).with_expiry(Some(ExpiryOption::KeepTtl)); + assert!(cmd_keepttl.calculate_expiry_time().is_none()); + + let cmd_none = SetCommand::new("k".into(), "v".into()).with_expiry(None); + assert!(cmd_none.calculate_expiry_time().is_none()); + } + } +} -- cgit v1.2.3