aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/resp_commands.rs301
1 files changed, 301 insertions, 0 deletions
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<RespType> 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<CacheEntry> {
+ 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<u8> {
+ 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());
+ }
+ }
+}