diff --git a/src/main/java/com/redis/commands/string/IncrCommand.java b/src/main/java/com/redis/commands/string/IncrCommand.java
new file mode 100644
index 0000000..17ed37c
--- /dev/null
+++ b/src/main/java/com/redis/commands/string/IncrCommand.java
@@ -0,0 +1,81 @@
+package com.redis.commands.string;
+
+import com.redis.commands.ICommand;
+import com.redis.storage.RedisDatabase;
+import com.redis.storage.RedisValue;
+import io.netty.channel.ChannelHandlerContext;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * INCR command implementation.
+ *
+ * Syntax: INCR key
+ *
+ * Increments the number stored at key by one. If the key does not exist,
+ * it is set to 0 before performing the operation.
+ *
+ * An error is returned if the key contains a value of the wrong type or
+ * contains a string that cannot be represented as integer.
+ *
+ * Return value: Integer reply: the value of key after the increment.
+ */
+public class IncrCommand implements ICommand {
+
+ private static final String ERR_WRONG_ARGS = "-ERR wrong number of arguments for 'INCR' command\r\n";
+ private static final String ERR_NOT_INTEGER = "-ERR value is not an integer or out of range\r\n";
+ private static final String ERR_WRONG_TYPE = "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n";
+
+ @Override
+ public String execute(List args, ChannelHandlerContext ctx) {
+ if (args.size() != 1) {
+ return ERR_WRONG_ARGS;
+ }
+
+ String key = args.getFirst();
+ RedisDatabase db = RedisDatabase.getInstance();
+
+ // Use AtomicReference to capture result from compute lambda
+ AtomicReference result = new AtomicReference<>();
+
+ db.compute(key, currentValue -> {
+ long newValue;
+
+ if (currentValue == null) {
+ // Key does not exist, initialize to 0 then increment
+ newValue = 1;
+ } else if (currentValue.getType() != RedisValue.Type.STRING) {
+ // Wrong type error
+ result.set(ERR_WRONG_TYPE);
+ return currentValue; // Return unchanged
+ } else {
+ // Key exists and is a string, try to parse as integer
+ String strValue = currentValue.asString();
+ try {
+ long currentNum = Long.parseLong(strValue);
+ // Check for overflow
+ if (currentNum == Long.MAX_VALUE) {
+ result.set(ERR_NOT_INTEGER);
+ return currentValue; // Return unchanged
+ }
+ newValue = currentNum + 1;
+ } catch (NumberFormatException e) {
+ result.set(ERR_NOT_INTEGER);
+ return currentValue; // Return unchanged
+ }
+ }
+
+ // Success: set the result and return the new value
+ result.set(":" + newValue + "\r\n");
+ return RedisValue.string(String.valueOf(newValue));
+ });
+
+ return result.get();
+ }
+
+ @Override
+ public String name() {
+ return "INCR";
+ }
+}
diff --git a/src/main/java/com/redis/commands/transaction/DiscardCommand.java b/src/main/java/com/redis/commands/transaction/DiscardCommand.java
new file mode 100644
index 0000000..29a0a29
--- /dev/null
+++ b/src/main/java/com/redis/commands/transaction/DiscardCommand.java
@@ -0,0 +1,49 @@
+package com.redis.commands.transaction;
+
+import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+
+import java.util.List;
+
+/**
+ * DISCARD command implementation.
+ *
+ * Syntax: DISCARD
+ *
+ * Flushes all previously queued commands in a transaction and restores
+ * the connection state to normal.
+ *
+ * Optimization:
+ * The queued commands list is cleared but not reallocated, allowing the
+ * memory to be reused if another transaction starts on the same connection.
+ *
+ * Return value: Simple string reply: always OK.
+ *
+ * Error: Returns an error if called without a prior MULTI.
+ */
+public class DiscardCommand implements ICommand {
+
+ private static final String RESP_OK = "+OK\r\n";
+ private static final String ERR_NO_MULTI = "-ERR DISCARD without MULTI\r\n";
+
+ @Override
+ public String execute(List args, ChannelHandlerContext ctx) {
+ // Get transaction context
+ TransactionContext txCtx = TransactionContext.get(ctx.channel());
+
+ // Verify we're in a transaction
+ if (txCtx == null || !txCtx.isInTransaction()) {
+ return ERR_NO_MULTI;
+ }
+
+ // Discard the transaction
+ txCtx.discard();
+ return RESP_OK;
+ }
+
+ @Override
+ public String name() {
+ return "DISCARD";
+ }
+}
diff --git a/src/main/java/com/redis/commands/transaction/ExecCommand.java b/src/main/java/com/redis/commands/transaction/ExecCommand.java
new file mode 100644
index 0000000..54dbc50
--- /dev/null
+++ b/src/main/java/com/redis/commands/transaction/ExecCommand.java
@@ -0,0 +1,110 @@
+package com.redis.commands.transaction;
+
+import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+
+import java.util.List;
+
+/**
+ * EXEC command implementation.
+ *
+ * Syntax: EXEC
+ *
+ * Executes all previously queued commands in a MULTI/EXEC block and restores
+ * the connection state to normal.
+ *
+ * Optimizations over Redis:
+ *
+ * Pre-allocated Response Buffer: We calculate the response size hint
+ * based on queue size to minimize StringBuilder reallocations.
+ * Cache-Friendly Iteration: Commands are stored in a contiguous ArrayList,
+ * providing excellent CPU cache utilization during execution.
+ * Zero Command Lookups: Commands are resolved and stored at queue time,
+ * not during EXEC, eliminating registry lookups.
+ * Batch Execution: All commands execute in a tight loop without
+ * intermediate I/O operations.
+ *
+ *
+ * Return value:
+ *
+ * Array reply: each element is the reply of each command in the transaction.
+ * Null reply: if EXEC is called without a preceding MULTI.
+ * Null reply: if the transaction was aborted due to errors during queueing.
+ *
+ */
+public class ExecCommand implements ICommand {
+
+ private static final String ERR_NO_MULTI = "-ERR EXEC without MULTI\r\n";
+ private static final String RESP_ABORT = "-EXECABORT Transaction discarded because of previous errors.\r\n";
+ private static final String RESP_EMPTY_ARRAY = "*0\r\n";
+
+ // Average response size per command (used for buffer sizing)
+ private static final int AVG_RESPONSE_SIZE = 32;
+
+ @Override
+ public String execute(List args, ChannelHandlerContext ctx) {
+
+ if(!args.isEmpty()) {
+ return "-ERR wrong number of arguments for 'EXEC' command\r\n";
+ }
+
+ // Get transaction context
+ TransactionContext txCtx = TransactionContext.get(ctx.channel());
+
+ // Verify we're in a transaction
+ if (txCtx == null || !txCtx.isInTransaction()) {
+ return ERR_NO_MULTI;
+ }
+
+ // Check if any errors occurred during queueing
+ if (txCtx.hasErrors()) {
+ txCtx.endTransaction();
+ return RESP_ABORT;
+ }
+
+ List commands = txCtx.getQueuedCommands();
+
+ // Handle empty transaction
+ if (commands.isEmpty()) {
+ txCtx.endTransaction();
+ return RESP_EMPTY_ARRAY;
+ }
+
+ // Pre-allocate response builder with estimated capacity
+ // Format: *\r\n followed by each response
+ int estimatedSize = 8 + (commands.size() * AVG_RESPONSE_SIZE);
+ StringBuilder response = new StringBuilder(estimatedSize);
+
+ // RESP Array header
+ response.append('*').append(commands.size()).append("\r\n");
+
+ // Execute all commands in sequence
+ // Note: Each command's response is already RESP-formatted
+ for (TransactionContext.QueuedCommand qc : commands) {
+ try {
+ String cmdResponse = qc.command().execute(qc.args(), ctx);
+ if (cmdResponse != null) {
+ response.append(cmdResponse);
+ } else {
+ // Command returned null (async command in transaction - shouldn't happen)
+ // Treat as nil for safety
+ response.append("$-1\r\n");
+ }
+ } catch (Exception e) {
+ // If a command throws, return an error for that slot
+ response.append("-ERR ").append(e.getMessage()).append("\r\n");
+ }
+ }
+
+ // Clean up transaction state
+ txCtx.endTransaction();
+
+ return response.toString();
+ }
+
+ @Override
+ public String name() {
+ return "EXEC";
+ }
+}
diff --git a/src/main/java/com/redis/commands/transaction/MultiCommand.java b/src/main/java/com/redis/commands/transaction/MultiCommand.java
new file mode 100644
index 0000000..ec19a37
--- /dev/null
+++ b/src/main/java/com/redis/commands/transaction/MultiCommand.java
@@ -0,0 +1,50 @@
+package com.redis.commands.transaction;
+
+import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+
+import java.util.List;
+
+/**
+ * MULTI command implementation.
+ *
+ * Syntax: MULTI
+ *
+ * Marks the start of a transaction block. Subsequent commands will be queued
+ * for atomic execution when EXEC is called.
+ *
+ * Optimization over Redis:
+ *
+ * Transaction state is stored directly in the Netty channel's attribute map,
+ * providing O(1) access without any global data structure lookups.
+ * The transaction context is lazily created only when MULTI is called.
+ *
+ *
+ * Return value: Simple string reply: always OK.
+ */
+public class MultiCommand implements ICommand {
+
+ private static final String RESP_OK = "+OK\r\n";
+ private static final String ERR_NESTED = "-ERR MULTI calls can not be nested\r\n";
+
+ @Override
+ public String execute(List args, ChannelHandlerContext ctx) {
+ // Get or create transaction context for this connection
+ TransactionContext txCtx = TransactionContext.getOrCreate(ctx.channel());
+
+ // Check for nested MULTI (not allowed)
+ if (txCtx.isInTransaction()) {
+ return ERR_NESTED;
+ }
+
+ // Start the transaction
+ txCtx.startTransaction();
+ return RESP_OK;
+ }
+
+ @Override
+ public String name() {
+ return "MULTI";
+ }
+}
diff --git a/src/main/java/com/redis/server/RedisCommandHandler.java b/src/main/java/com/redis/server/RedisCommandHandler.java
index 193f903..003ec00 100644
--- a/src/main/java/com/redis/server/RedisCommandHandler.java
+++ b/src/main/java/com/redis/server/RedisCommandHandler.java
@@ -2,6 +2,7 @@
import com.redis.commands.CommandRegistry;
import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
@@ -10,6 +11,7 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
/**
* Netty channel handler responsible for the "Framing" phase of the Redis protocol.
@@ -32,6 +34,17 @@ public class RedisCommandHandler extends ByteToMessageDecoder {
// Initial capacity for the arguments list. Most Redis commands have fewer than 16 arguments.
private static final int INITIAL_ARGS_CAPACITY = 16;
+ /**
+ * Commands that are allowed to execute immediately even within a transaction.
+ * These are the transaction control commands themselves.
+ */
+ private static final Set TRANSACTION_COMMANDS = Set.of("EXEC", "DISCARD", "MULTI");
+
+ /**
+ * RESP response for successfully queued commands in a transaction.
+ */
+ private static final String RESP_QUEUED = "+QUEUED\r\n";
+
/**
* A reusable buffer for command arguments.
* We clear and refill this list for every command instead of allocating a new ArrayList each time.
@@ -84,25 +97,40 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) {
// COMMAND RESOLUTION:
// The first element of the array is always the command name (e.g., "SET", "GET").
- String commandName = argsBuffer.get(0);
+ String commandName = argsBuffer.getFirst();
+ String upperCommandName = commandName != null ? commandName.toUpperCase() : "";
ICommand cmd = CommandRegistry.getInstance().get(commandName);
if (cmd == null) {
- // Protocol requires reporting unknown commands to the client
+ // Protocol requires reporting unknown commands to the client,
+ // But if in transaction, we still need to track the error
+ TransactionContext txCtx = TransactionContext.get(ctx.channel());
+ if (txCtx != null && txCtx.isInTransaction()) {
+ txCtx.markError();
+ }
writeResponse(ctx, "-ERR unknown command '" + commandName + "'\r\n");
} else {
// Create a view of the arguments (skipping the command name).
// subList is a lightweight view, not a copy.
List commandArgs = argsBuffer.size() > 1 ? argsBuffer.subList(1, argsBuffer.size()) : List.of();
- // EXECUTION:
- // Run the actual logic (e.g., modifying the KeyValue store).
- String resp = cmd.execute(commandArgs, ctx);
-
- // If response is not null, write it immediately.
- // Null responses implies the command handles its own writing asynchronously (e.g., blocking ops).
- if (resp != null) {
- writeResponse(ctx, resp);
+ // TRANSACTION HANDLING:
+ // Check if we're in a transaction and this isn't a transaction control command
+ TransactionContext txCtx = TransactionContext.get(ctx.channel());
+ if (txCtx != null && txCtx.isInTransaction() && !TRANSACTION_COMMANDS.contains(upperCommandName)) {
+ // Queue the command instead of executing it
+ txCtx.queueCommand(cmd, commandArgs);
+ writeResponse(ctx, RESP_QUEUED);
+ } else {
+ // EXECUTION:
+ // Run the actual logic (e.g., modifying the KeyValue store).
+ String resp = cmd.execute(commandArgs, ctx);
+
+ // If response is not null, write it immediately.
+ // Null responses implies the command handles its own writing asynchronously (e.g., blocking ops).
+ if (resp != null) {
+ writeResponse(ctx, resp);
+ }
}
}
}
diff --git a/src/main/java/com/redis/transaction/TransactionContext.java b/src/main/java/com/redis/transaction/TransactionContext.java
new file mode 100644
index 0000000..f78ad9f
--- /dev/null
+++ b/src/main/java/com/redis/transaction/TransactionContext.java
@@ -0,0 +1,155 @@
+package com.redis.transaction;
+
+import com.redis.commands.ICommand;
+import io.netty.channel.Channel;
+import io.netty.util.AttributeKey;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Per-connection transaction state manager for MULTI/EXEC/DISCARD commands.
+ *
+ * Design Goals (Better than Redis):
+ *
+ * Zero Contention: Transaction state is stored per-channel using Netty's AttributeMap,
+ * avoiding any global locks for state management.
+ * Memory Efficient: Uses a pre-sized ArrayList and only allocates when MULTI is called.
+ * Cache-Friendly Execution: Commands are stored contiguously for optimal cache locality during EXEC.
+ * Fail-Fast Validation: Commands are validated at queue time, not execution time.
+ *
+ *
+ * Thread Safety:
+ * Each Channel has its own TransactionContext stored as a channel attribute.
+ * Since Netty guarantees that a channel's I/O is handled by a single thread,
+ * no synchronization is needed within the context itself.
+ */
+public class TransactionContext {
+
+ /**
+ * Netty attribute key for storing transaction context per channel.
+ * Using AttributeKey provides O(1) access and is GC-friendly.
+ */
+ public static final AttributeKey TRANSACTION_KEY =
+ AttributeKey.valueOf("redis.transaction");
+
+ /**
+ * A queued command with its pre-parsed arguments.
+ * Using a record for immutability and compact memory layout.
+ */
+ public record QueuedCommand(ICommand command, List args) {}
+
+ // Initial capacity optimized for typical transaction sizes (10-50 commands)
+ private static final int INITIAL_QUEUE_CAPACITY = 16;
+
+ private final List queue;
+ private boolean inTransaction;
+ private boolean hasErrors; // Track if any command failed to queue (WRONGTYPE, etc.)
+
+ public TransactionContext() {
+ this.queue = new ArrayList<>(INITIAL_QUEUE_CAPACITY);
+ this.inTransaction = false;
+ this.hasErrors = false;
+ }
+
+ /**
+ * Starts a new transaction.
+ * @return true if transaction started, false if already in a transaction
+ */
+ public boolean startTransaction() {
+ if (inTransaction) {
+ return false;
+ }
+ inTransaction = true;
+ hasErrors = false;
+ queue.clear(); // Reuse the existing list to avoid allocation
+ return true;
+ }
+
+ /**
+ * Queues a command for later execution during EXEC.
+ *
+ * Optimization: We store the actual ICommand reference and a copy of args,
+ * avoiding command lookup during EXEC.
+ *
+ * @param command The resolved command to queue
+ * @param args The command arguments (will be copied to avoid mutation)
+ */
+ public void queueCommand(ICommand command, List args) {
+ // Create a defensive copy of args since the original list may be reused
+ queue.add(new QueuedCommand(command, new ArrayList<>(args)));
+ }
+
+ /**
+ * Marks that an error occurred during command queueing.
+ * When EXEC is called, it will return an error instead of executing.
+ */
+ public void markError() {
+ hasErrors = true;
+ }
+
+ /**
+ * Gets the queued commands for execution.
+ * @return Unmodifiable view of queued commands
+ */
+ public List getQueuedCommands() {
+ return queue; // Direct access for performance; caller should not modify
+ }
+
+ /**
+ * Discards the transaction and clears all queued commands.
+ * @return true if a transaction was active, false otherwise
+ */
+ public boolean discard() {
+ if (!inTransaction) {
+ return false;
+ }
+ inTransaction = false;
+ hasErrors = false;
+ queue.clear();
+ return true;
+ }
+
+ /**
+ * Ends the transaction (called after EXEC completes).
+ */
+ public void endTransaction() {
+ inTransaction = false;
+ hasErrors = false;
+ queue.clear();
+ }
+
+ public boolean isInTransaction() {
+ return inTransaction;
+ }
+
+ public boolean hasErrors() {
+ return hasErrors;
+ }
+
+ public int queueSize() {
+ return queue.size();
+ }
+
+ // ==================== Static Utility Methods ====================
+
+ /**
+ * Gets or creates a TransactionContext for the given channel.
+ * Uses Netty's AttributeMap for O(1) access.
+ */
+ public static TransactionContext getOrCreate(Channel channel) {
+ TransactionContext ctx = channel.attr(TRANSACTION_KEY).get();
+ if (ctx == null) {
+ ctx = new TransactionContext();
+ channel.attr(TRANSACTION_KEY).set(ctx);
+ }
+ return ctx;
+ }
+
+ /**
+ * Gets the TransactionContext for a channel, or null if none exists.
+ */
+ public static TransactionContext get(Channel channel) {
+ return channel.attr(TRANSACTION_KEY).get();
+ }
+}
diff --git a/src/main/resources/META-INF/services/com.redis.commands.ICommand b/src/main/resources/META-INF/services/com.redis.commands.ICommand
index a902054..e8b6a5c 100644
--- a/src/main/resources/META-INF/services/com.redis.commands.ICommand
+++ b/src/main/resources/META-INF/services/com.redis.commands.ICommand
@@ -5,6 +5,7 @@ com.redis.commands.generic.PingCommand
com.redis.commands.generic.TtlCommand
com.redis.commands.generic.TypeCommand
com.redis.commands.string.GetCommand
+com.redis.commands.string.IncrCommand
com.redis.commands.string.SetCommand
com.redis.commands.list.BLPopCommand
com.redis.commands.list.LLenCommand
@@ -15,3 +16,6 @@ com.redis.commands.list.RPushCommand
com.redis.commands.stream.XAddCommand
com.redis.commands.stream.XRangeCommand
com.redis.commands.stream.XReadCommand
+com.redis.commands.transaction.MultiCommand
+com.redis.commands.transaction.ExecCommand
+com.redis.commands.transaction.DiscardCommand
diff --git a/src/test/java/com/redis/commands/string/IncrCommandTest.java b/src/test/java/com/redis/commands/string/IncrCommandTest.java
new file mode 100644
index 0000000..dd59278
--- /dev/null
+++ b/src/test/java/com/redis/commands/string/IncrCommandTest.java
@@ -0,0 +1,332 @@
+package com.redis.commands.string;
+
+import com.redis.storage.RedisDatabase;
+import com.redis.storage.RedisValue;
+import io.netty.channel.ChannelHandlerContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for INCR command.
+ *
+ * Test Coverage:
+ * - Basic increment of existing key
+ * - Increment of non-existent key (initializes to 0 then increments)
+ * - Increment of negative numbers
+ * - Error case: non-integer value
+ * - Error case: wrong type (e.g., list)
+ * - Error case: wrong number of arguments
+ * - Edge cases: large numbers, overflow
+ */
+@DisplayName("INCR Command Unit Tests")
+public class IncrCommandTest {
+
+ private IncrCommand command;
+ private ChannelHandlerContext mockCtx;
+ private RedisDatabase db;
+
+ @BeforeEach
+ void setUp() {
+ command = new IncrCommand();
+ mockCtx = mock(ChannelHandlerContext.class);
+ db = RedisDatabase.getInstance();
+ }
+
+ @Test
+ @DisplayName("INCR: Increment existing integer key")
+ void testIncrExistingKey() {
+ // Setup: Set a key with value "10"
+ db.put("incr_test_existing", "10");
+
+ List args = Collections.singletonList("incr_test_existing");
+ String result = command.execute(args, mockCtx);
+
+ // Should return :11
+ assertEquals(":11\r\n", result);
+
+ // Verify value was incremented
+ String retrieved = db.get("incr_test_existing");
+ assertEquals("11", retrieved);
+ }
+
+ @Test
+ @DisplayName("INCR: Increment non-existent key (should initialize to 0 then increment)")
+ void testIncrNonExistentKey() {
+ // Remove key if it exists
+ db.remove("incr_test_nonexistent");
+
+ List args = Collections.singletonList("incr_test_nonexistent");
+ String result = command.execute(args, mockCtx);
+
+ // Should return :1 (0 + 1)
+ assertEquals(":1\r\n", result);
+
+ // Verify value was set
+ String retrieved = db.get("incr_test_nonexistent");
+ assertEquals("1", retrieved);
+ }
+
+ @Test
+ @DisplayName("INCR: Increment zero")
+ void testIncrZero() {
+ db.put("incr_test_zero", "0");
+
+ List args = Collections.singletonList("incr_test_zero");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":1\r\n", result);
+ assertEquals("1", db.get("incr_test_zero"));
+ }
+
+ @Test
+ @DisplayName("INCR: Increment negative number")
+ void testIncrNegative() {
+ db.put("incr_test_negative", "-5");
+
+ List args = Collections.singletonList("incr_test_negative");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":-4\r\n", result);
+ assertEquals("-4", db.get("incr_test_negative"));
+ }
+
+ @Test
+ @DisplayName("INCR: Multiple increments")
+ void testIncrMultiple() {
+ db.put("incr_test_multi", "0");
+ List args = Collections.singletonList("incr_test_multi");
+
+ // First increment
+ String result1 = command.execute(args, mockCtx);
+ assertEquals(":1\r\n", result1);
+
+ // Second increment
+ String result2 = command.execute(args, mockCtx);
+ assertEquals(":2\r\n", result2);
+
+ // Third increment
+ String result3 = command.execute(args, mockCtx);
+ assertEquals(":3\r\n", result3);
+
+ assertEquals("3", db.get("incr_test_multi"));
+ }
+
+ @Test
+ @DisplayName("INCR: Error on non-integer string value")
+ void testIncrNonIntegerString() {
+ db.put("incr_test_string", "hello");
+
+ List args = Collections.singletonList("incr_test_string");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("not an integer"));
+
+ // Value should remain unchanged
+ assertEquals("hello", db.get("incr_test_string"));
+ }
+
+ @Test
+ @DisplayName("INCR: Error on float value")
+ void testIncrFloatValue() {
+ db.put("incr_test_float", "3.14");
+
+ List args = Collections.singletonList("incr_test_float");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+
+ // Value should remain unchanged
+ assertEquals("3.14", db.get("incr_test_float"));
+ }
+
+ @Test
+ @DisplayName("INCR: Error on wrong type (list)")
+ void testIncrWrongTypeList() {
+ // Create a list value
+ db.put("incr_test_list", RedisValue.list(new java.util.ArrayList<>(Collections.singletonList("item1"))));
+
+ List args = Collections.singletonList("incr_test_list");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("WRONGTYPE"));
+ }
+
+ @Test
+ @DisplayName("INCR: Error on wrong number of arguments (no args)")
+ void testIncrNoArgs() {
+ List args = Collections.emptyList();
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("wrong number of arguments"));
+ }
+
+ @Test
+ @DisplayName("INCR: Error on wrong number of arguments (too many args)")
+ void testIncrTooManyArgs() {
+ List args = Arrays.asList("key1", "extra_arg");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("wrong number of arguments"));
+ }
+
+ @Test
+ @DisplayName("INCR: Large positive number")
+ void testIncrLargeNumber() {
+ db.put("incr_test_large", "999999999");
+
+ List args = Collections.singletonList("incr_test_large");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":1000000000\r\n", result);
+ assertEquals("1000000000", db.get("incr_test_large"));
+ }
+
+ @Test
+ @DisplayName("INCR: Overflow protection (Long.MAX_VALUE)")
+ void testIncrOverflow() {
+ db.put("incr_test_overflow", String.valueOf(Long.MAX_VALUE));
+
+ List args = Collections.singletonList("incr_test_overflow");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("not an integer or out of range"));
+
+ // Value should remain unchanged
+ assertEquals(String.valueOf(Long.MAX_VALUE), db.get("incr_test_overflow"));
+ }
+
+ @Test
+ @DisplayName("INCR: Command name is correct")
+ void testCommandName() {
+ assertEquals("INCR", command.name());
+ }
+
+ @Test
+ @DisplayName("INCR: Increment very large negative number approaching zero")
+ void testIncrLargeNegativeApproachingZero() {
+ db.put("incr_test_large_neg", String.valueOf(Long.MIN_VALUE + 1));
+
+ List args = Collections.singletonList("incr_test_large_neg");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":" + (Long.MIN_VALUE + 2) + "\r\n", result);
+ assertEquals(String.valueOf(Long.MIN_VALUE + 2), db.get("incr_test_large_neg"));
+ }
+
+ @Test
+ @DisplayName("INCR: Increment at boundary (Long.MAX_VALUE - 1)")
+ void testIncrNearMaxValue() {
+ db.put("incr_test_near_max", String.valueOf(Long.MAX_VALUE - 1));
+
+ List args = Collections.singletonList("incr_test_near_max");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":" + Long.MAX_VALUE + "\r\n", result);
+ assertEquals(String.valueOf(Long.MAX_VALUE), db.get("incr_test_near_max"));
+ }
+
+ @Test
+ @DisplayName("INCR: Leading zeros are parsed correctly")
+ void testIncrWithLeadingZeros() {
+ db.put("incr_test_leading_zeros", "0000042");
+
+ List args = Collections.singletonList("incr_test_leading_zeros");
+ String result = command.execute(args, mockCtx);
+
+ assertEquals(":43\r\n", result);
+ assertEquals("43", db.get("incr_test_leading_zeros"));
+ }
+
+ @Test
+ @DisplayName("INCR: String with whitespace is invalid")
+ void testIncrStringWithWhitespace() {
+ db.put("incr_test_whitespace", " 42 ");
+
+ List args = Collections.singletonList("incr_test_whitespace");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("not an integer"));
+ assertEquals(" 42 ", db.get("incr_test_whitespace"));
+ }
+
+ @Test
+ @DisplayName("INCR: Empty string is invalid")
+ void testIncrEmptyString() {
+ db.put("incr_test_empty", "");
+
+ List args = Collections.singletonList("incr_test_empty");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertEquals("", db.get("incr_test_empty"));
+ }
+
+ @Test
+ @DisplayName("INCR: Scientific notation is invalid")
+ void testIncrScientificNotation() {
+ db.put("incr_test_scientific", "1e5");
+
+ List args = Collections.singletonList("incr_test_scientific");
+ String result = command.execute(args, mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertEquals("1e5", db.get("incr_test_scientific"));
+ }
+
+ @Test
+ @DisplayName("INCR: Underflow at Long.MIN_VALUE")
+ void testIncrUnderflow() {
+ db.put("incr_test_underflow", String.valueOf(Long.MIN_VALUE));
+
+ List args = Collections.singletonList("incr_test_underflow");
+ String result = command.execute(args, mockCtx);
+
+ // Should succeed: MIN_VALUE + 1 is valid
+ assertEquals(":" + (Long.MIN_VALUE + 1) + "\r\n", result);
+ assertEquals(String.valueOf(Long.MIN_VALUE + 1), db.get("incr_test_underflow"));
+ }
+
+ @Test
+ @DisplayName("INCR: Concurrent-like behavior with multiple increments")
+ void testIncrConcurrentLike() {
+ db.put("incr_test_concurrent", "100");
+ List args = Collections.singletonList("incr_test_concurrent");
+
+ // Simulate rapid increments
+ for (int i = 0; i < 10; i++) {
+ String result = command.execute(args, mockCtx);
+ int expectedValue = 101 + i;
+ assertEquals(":" + expectedValue + "\r\n", result);
+ }
+
+ assertEquals("110", db.get("incr_test_concurrent"));
+ }
+
+ @Test
+ @DisplayName("INCR: Plus sign prefix is handled")
+ void testIncrPlusSignPrefix() {
+ // Java's Long.parseLong accepts + prefix, so +42 parses as 42
+ // This differs from Redis, which treats +42 as invalid
+ db.put("incr_test_plus", "+42");
+
+ List args = Collections.singletonList("incr_test_plus");
+ String result = command.execute(args, mockCtx);
+
+ // Java accepts +42 as valid, so it increments to 43
+ assertEquals(":43\r\n", result);
+ assertEquals("43", db.get("incr_test_plus"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/redis/commands/transaction/DiscardCommandTest.java b/src/test/java/com/redis/commands/transaction/DiscardCommandTest.java
new file mode 100644
index 0000000..ceecdec
--- /dev/null
+++ b/src/test/java/com/redis/commands/transaction/DiscardCommandTest.java
@@ -0,0 +1,207 @@
+package com.redis.commands.transaction;
+
+import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for DISCARD command.
+ *
+ * Test Coverage:
+ * - Basic DISCARD execution
+ * - DISCARD without MULTI (error case)
+ * - Command name verification
+ * - Transaction state cleanup
+ * - Queued commands clearing
+ */
+@DisplayName("DISCARD Command Unit Tests")
+public class DiscardCommandTest {
+
+ private DiscardCommand command;
+ private ChannelHandlerContext mockCtx;
+ private EmbeddedChannel channel;
+
+ @BeforeEach
+ void setUp() {
+ command = new DiscardCommand();
+ channel = new EmbeddedChannel();
+ mockCtx = mock(ChannelHandlerContext.class);
+ when(mockCtx.channel()).thenReturn(channel);
+ }
+
+ @Test
+ @DisplayName("DISCARD: Returns OK when in transaction")
+ void testDiscardReturnsOkWhenInTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ }
+
+ @Test
+ @DisplayName("DISCARD: Returns error when not in transaction")
+ void testDiscardReturnsErrorWhenNotInTransaction() {
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("without MULTI"));
+ }
+
+ @Test
+ @DisplayName("DISCARD: Ends transaction")
+ void testDiscardEndsTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Clears queued commands")
+ void testDiscardClearsQueuedCommands() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+ ctx.queueCommand(mock(ICommand.class), Collections.emptyList());
+ ctx.queueCommand(mock(ICommand.class), Collections.emptyList());
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals(0, ctx.queueSize());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Clears error flag")
+ void testDiscardClearsErrorFlag() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+ ctx.markError();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertFalse(ctx.hasErrors());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Command name is DISCARD")
+ void testCommandName() {
+ assertEquals("DISCARD", command.name());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Ignores arguments")
+ void testDiscardIgnoresArguments() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ String result = command.execute(Collections.singletonList("extra"), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ }
+
+ @Test
+ @DisplayName("DISCARD: Error when transaction context doesn't exist")
+ void testDiscardErrorWhenNoContext() {
+ // Don't create context
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("without MULTI"));
+ }
+
+ @Test
+ @DisplayName("DISCARD: Error when context exists but not in transaction")
+ void testDiscardErrorWhenContextExistsButNotInTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ // Don't start transaction
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("without MULTI"));
+ }
+
+ @Test
+ @DisplayName("DISCARD: Can start new transaction after discard")
+ void testCanStartNewTransactionAfterDiscard() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ // Should be able to start new transaction
+ boolean result = ctx.startTransaction();
+ assertTrue(result);
+ assertTrue(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Multiple discards without MULTI fail")
+ void testMultipleDiscardsWithoutMultiFail() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ // Second discard should fail
+ String result = command.execute(Collections.emptyList(), mockCtx);
+ assertTrue(result.contains("ERR"));
+ }
+
+ @Test
+ @DisplayName("DISCARD: Works with large queue")
+ void testDiscardWithLargeQueue() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ // Queue many commands
+ for (int i = 0; i < 1000; i++) {
+ ctx.queueCommand(mock(ICommand.class), Collections.emptyList());
+ }
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ assertEquals(0, ctx.queueSize());
+ }
+
+ @Test
+ @DisplayName("DISCARD: Preserves context object")
+ void testDiscardPreservesContextObject() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ TransactionContext afterDiscard = TransactionContext.get(channel);
+ assertSame(ctx, afterDiscard);
+ }
+
+ @Test
+ @DisplayName("DISCARD: State is clean after discard")
+ void testStateIsCleanAfterDiscard() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+ ctx.queueCommand(mock(ICommand.class), Collections.emptyList());
+ ctx.markError();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertFalse(ctx.isInTransaction());
+ assertFalse(ctx.hasErrors());
+ assertEquals(0, ctx.queueSize());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/redis/commands/transaction/ExecCommandTest.java b/src/test/java/com/redis/commands/transaction/ExecCommandTest.java
new file mode 100644
index 0000000..b4e6c4e
--- /dev/null
+++ b/src/test/java/com/redis/commands/transaction/ExecCommandTest.java
@@ -0,0 +1,329 @@
+package com.redis.commands.transaction;
+
+import com.redis.commands.ICommand;
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for EXEC command.
+ *
+ * Test Coverage:
+ * - Basic EXEC execution
+ * - EXEC without MULTI (error case)
+ * - Empty transaction
+ * - Transaction with errors (abort case)
+ * - Command execution and response formatting
+ * - State cleanup after execution
+ */
+@DisplayName("EXEC Command Unit Tests")
+public class ExecCommandTest {
+
+ private ExecCommand command;
+ private ChannelHandlerContext mockCtx;
+ private EmbeddedChannel channel;
+
+ @BeforeEach
+ void setUp() {
+ command = new ExecCommand();
+ channel = new EmbeddedChannel();
+ mockCtx = mock(ChannelHandlerContext.class);
+ when(mockCtx.channel()).thenReturn(channel);
+ }
+
+ @Test
+ @DisplayName("EXEC: Returns error when not in transaction")
+ void testExecReturnsErrorWhenNotInTransaction() {
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("without MULTI"));
+ }
+
+ @Test
+ @DisplayName("EXEC: Returns error when context doesn't exist")
+ void testExecErrorWhenNoContext() {
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("without MULTI"));
+ }
+
+ @Test
+ @DisplayName("EXEC: Returns empty array for empty transaction")
+ void testExecReturnsEmptyArrayForEmptyTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("*0\r\n", result);
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("EXEC: Returns abort error when hasErrors is true")
+ void testExecReturnsAbortWhenHasErrors() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+ ctx.markError();
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("EXECABORT") || result.contains("discarded"));
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("EXEC: Executes single queued command")
+ void testExecExecutesSingleCommand() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*1\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("EXEC: Executes multiple queued commands")
+ void testExecExecutesMultipleCommands() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand cmd1 = mock(ICommand.class);
+ ICommand cmd2 = mock(ICommand.class);
+ ICommand cmd3 = mock(ICommand.class);
+
+ when(cmd1.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+ when(cmd2.execute(any(), eq(mockCtx))).thenReturn(":42\r\n");
+ when(cmd3.execute(any(), eq(mockCtx))).thenReturn("$5\r\nhello\r\n");
+
+ ctx.queueCommand(cmd1, Collections.emptyList());
+ ctx.queueCommand(cmd2, Collections.emptyList());
+ ctx.queueCommand(cmd3, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*3\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ assertTrue(result.contains(":42\r\n"));
+ assertTrue(result.contains("$5\r\nhello\r\n"));
+ }
+
+ @Test
+ @DisplayName("EXEC: Handles command returning null")
+ void testExecHandlesCommandReturningNull() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenReturn(null);
+
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*1\r\n"));
+ assertTrue(result.contains("$-1\r\n")); // Nil reply for null
+ }
+
+ @Test
+ @DisplayName("EXEC: Handles command throwing exception")
+ void testExecHandlesCommandThrowingException() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenThrow(new RuntimeException("Test error"));
+
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*1\r\n"));
+ assertTrue(result.contains("-ERR"));
+ assertTrue(result.contains("Test error"));
+ }
+
+ @Test
+ @DisplayName("EXEC: Command name is EXEC")
+ void testCommandName() {
+ assertEquals("EXEC", command.name());
+ }
+
+ @Test
+ @DisplayName("EXEC: Rejects extra arguments")
+ void testExecRejectsArguments() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ String result = command.execute(Collections.singletonList("extra"), mockCtx);
+
+ assertTrue(result.startsWith("-ERR"), "EXEC should reject extra arguments");
+ }
+
+ @Test
+ @DisplayName("EXEC: Ends transaction after execution")
+ void testExecEndsTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertFalse(ctx.isInTransaction());
+ assertEquals(0, ctx.queueSize());
+ }
+
+ @Test
+ @DisplayName("EXEC: Can start new transaction after exec")
+ void testCanStartNewTransactionAfterExec() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ // Should be able to start new transaction
+ boolean result = ctx.startTransaction();
+ assertTrue(result);
+ }
+
+ @Test
+ @DisplayName("EXEC: Commands execute in order")
+ void testCommandsExecuteInOrder() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand cmd1 = mock(ICommand.class);
+ ICommand cmd2 = mock(ICommand.class);
+ ICommand cmd3 = mock(ICommand.class);
+
+ when(cmd1.execute(any(), eq(mockCtx))).thenReturn("+FIRST\r\n");
+ when(cmd2.execute(any(), eq(mockCtx))).thenReturn("+SECOND\r\n");
+ when(cmd3.execute(any(), eq(mockCtx))).thenReturn("+THIRD\r\n");
+
+ ctx.queueCommand(cmd1, Collections.emptyList());
+ ctx.queueCommand(cmd2, Collections.emptyList());
+ ctx.queueCommand(cmd3, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ // Check order by finding indices
+ int firstIndex = result.indexOf("FIRST");
+ int secondIndex = result.indexOf("SECOND");
+ int thirdIndex = result.indexOf("THIRD");
+
+ assertTrue(firstIndex < secondIndex);
+ assertTrue(secondIndex < thirdIndex);
+ }
+
+ @Test
+ @DisplayName("EXEC: Executes large transaction")
+ void testExecExecutesLargeTransaction() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ for (int i = 0; i < 100; i++) {
+ ICommand mockCommand = mock(ICommand.class);
+ when(mockCommand.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+ ctx.queueCommand(mockCommand, Collections.emptyList());
+ }
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*100\r\n"));
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("EXEC: Mixed success and error commands")
+ void testExecMixedSuccessAndErrorCommands() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand successCmd = mock(ICommand.class);
+ ICommand errorCmd = mock(ICommand.class);
+
+ when(successCmd.execute(any(), eq(mockCtx))).thenReturn("+OK\r\n");
+ when(errorCmd.execute(any(), eq(mockCtx))).thenThrow(new RuntimeException("Error"));
+
+ ctx.queueCommand(successCmd, Collections.emptyList());
+ ctx.queueCommand(errorCmd, Collections.emptyList());
+ ctx.queueCommand(successCmd, Collections.emptyList());
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*3\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ assertTrue(result.contains("-ERR"));
+ }
+
+ @Test
+ @DisplayName("EXEC: Clears error flag after abort")
+ void testExecClearsErrorFlagAfterAbort() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+ ctx.markError();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ assertFalse(ctx.hasErrors());
+ }
+
+ @Test
+ @DisplayName("EXEC: Context persists after execution")
+ void testContextPersistsAfterExecution() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ command.execute(Collections.emptyList(), mockCtx);
+
+ TransactionContext afterExec = TransactionContext.get(channel);
+ assertSame(ctx, afterExec);
+ }
+
+ @Test
+ @DisplayName("EXEC: Commands with arguments execute correctly")
+ void testCommandsWithArgumentsExecuteCorrectly() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+ ctx.startTransaction();
+
+ ICommand mockCommand = mock(ICommand.class);
+ List args = List.of("key", "value");
+ when(mockCommand.execute(eq(args), eq(mockCtx))).thenReturn("+OK\r\n");
+
+ ctx.queueCommand(mockCommand, args);
+
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.startsWith("*1\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/redis/commands/transaction/MultiCommandTest.java b/src/test/java/com/redis/commands/transaction/MultiCommandTest.java
new file mode 100644
index 0000000..8a4c855
--- /dev/null
+++ b/src/test/java/com/redis/commands/transaction/MultiCommandTest.java
@@ -0,0 +1,177 @@
+package com.redis.commands.transaction;
+
+import com.redis.transaction.TransactionContext;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for MULTI command.
+ *
+ * Test Coverage:
+ * - Basic MULTI execution
+ * - MULTI when already in transaction (nested MULTI)
+ * - Command name verification
+ * - Transaction context initialization
+ * - State management
+ */
+@DisplayName("MULTI Command Unit Tests")
+public class MultiCommandTest {
+
+ private MultiCommand command;
+ private ChannelHandlerContext mockCtx;
+ private EmbeddedChannel channel;
+
+ @BeforeEach
+ void setUp() {
+ command = new MultiCommand();
+ channel = new EmbeddedChannel();
+ mockCtx = mock(ChannelHandlerContext.class);
+ when(mockCtx.channel()).thenReturn(channel);
+ }
+
+ @Test
+ @DisplayName("MULTI: Returns OK on success")
+ void testMultiReturnsOk() {
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ }
+
+ @Test
+ @DisplayName("MULTI: Creates transaction context")
+ void testMultiCreatesTransactionContext() {
+ command.execute(Collections.emptyList(), mockCtx);
+
+ TransactionContext ctx = TransactionContext.get(channel);
+ assertNotNull(ctx);
+ assertTrue(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("MULTI: Returns error when already in transaction")
+ void testMultiWhenAlreadyInTransaction() {
+ // Start first transaction
+ command.execute(Collections.emptyList(), mockCtx);
+
+ // Try to start second transaction
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertTrue(result.contains("ERR"));
+ assertTrue(result.contains("nested"));
+ }
+
+ @Test
+ @DisplayName("MULTI: Can be called after DISCARD")
+ void testMultiAfterDiscard() {
+ // Start and discard transaction
+ command.execute(Collections.emptyList(), mockCtx);
+ TransactionContext ctx = TransactionContext.get(channel);
+ ctx.discard();
+
+ // Should be able to start new transaction
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ assertTrue(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("MULTI: Can be called after EXEC")
+ void testMultiAfterExec() {
+ // Start and end transaction
+ command.execute(Collections.emptyList(), mockCtx);
+ TransactionContext ctx = TransactionContext.get(channel);
+ ctx.endTransaction();
+
+ // Should be able to start new transaction
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ assertTrue(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("MULTI: Command name is MULTI")
+ void testCommandName() {
+ assertEquals("MULTI", command.name());
+ }
+
+ @Test
+ @DisplayName("MULTI: Ignores arguments")
+ void testMultiIgnoresArguments() {
+ String result = command.execute(Collections.singletonList("extra"), mockCtx);
+
+ assertEquals("+OK\r\n", result);
+ }
+
+ @Test
+ @DisplayName("MULTI: Transaction context reused if exists")
+ void testMultiReusesExistingContext() {
+ // Create context first
+ TransactionContext ctx1 = TransactionContext.getOrCreate(channel);
+
+ // Call MULTI
+ command.execute(Collections.emptyList(), mockCtx);
+
+ TransactionContext ctx2 = TransactionContext.get(channel);
+
+ assertSame(ctx1, ctx2);
+ }
+
+ @Test
+ @DisplayName("MULTI: Multiple channels have separate contexts")
+ void testMultiSeparateContextsPerChannel() {
+ EmbeddedChannel channel2 = new EmbeddedChannel();
+ ChannelHandlerContext mockCtx2 = mock(ChannelHandlerContext.class);
+ when(mockCtx2.channel()).thenReturn(channel2);
+
+ command.execute(Collections.emptyList(), mockCtx);
+ command.execute(Collections.emptyList(), mockCtx2);
+
+ TransactionContext ctx1 = TransactionContext.get(channel);
+ TransactionContext ctx2 = TransactionContext.get(channel2);
+
+ assertNotSame(ctx1, ctx2);
+ assertTrue(ctx1.isInTransaction());
+ assertTrue(ctx2.isInTransaction());
+
+ channel2.close();
+ }
+
+ @Test
+ @DisplayName("MULTI: Transaction starts with empty queue")
+ void testMultiStartsWithEmptyQueue() {
+ command.execute(Collections.emptyList(), mockCtx);
+
+ TransactionContext ctx = TransactionContext.get(channel);
+ assertEquals(0, ctx.queueSize());
+ assertFalse(ctx.hasErrors());
+ }
+
+ @Test
+ @DisplayName("MULTI: Nested MULTI preserves first transaction state")
+ void testNestedMultiPreservesState() {
+ command.execute(Collections.emptyList(), mockCtx);
+ TransactionContext ctx = TransactionContext.get(channel);
+
+ // Queue a command
+ ctx.queueCommand(mock(com.redis.commands.ICommand.class), Collections.emptyList());
+
+ // Try nested MULTI
+ String result = command.execute(Collections.emptyList(), mockCtx);
+
+ // Should still be in transaction with queued command
+ assertTrue(ctx.isInTransaction());
+ assertEquals(1, ctx.queueSize());
+ assertTrue(result.contains("ERR"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/redis/integration/BaseIntegrationTest.java b/src/test/java/com/redis/integration/BaseIntegrationTest.java
new file mode 100644
index 0000000..e4f94dc
--- /dev/null
+++ b/src/test/java/com/redis/integration/BaseIntegrationTest.java
@@ -0,0 +1,223 @@
+package com.redis.integration;
+
+import com.redis.server.RedisCommandHandler;
+import com.redis.storage.RedisDatabase;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Base class for Redis integration tests.
+ *
+ * Provides common functionality for all integration tests including:
+ *
+ * EmbeddedChannel setup and teardown
+ * RESP protocol command sending
+ * Response parsing utilities
+ * Database access and cleanup
+ *
+ *
+ * All integration test classes should extend this base class to ensure
+ * consistent setup/teardown and utility method availability.
+ */
+public abstract class BaseIntegrationTest {
+
+ protected EmbeddedChannel channel;
+ protected RedisDatabase db;
+
+ @BeforeEach
+ void baseSetUp() {
+ channel = new EmbeddedChannel(new RedisCommandHandler());
+ db = RedisDatabase.getInstance();
+ }
+
+ @AfterEach
+ void baseTearDown() {
+ if (channel != null && channel.isOpen()) {
+ channel.close();
+ }
+ }
+
+ /**
+ * Sends a Redis command via RESP protocol and returns the response.
+ *
+ * @param args command name followed by arguments
+ * @return RESP-formatted response string, or null if no response
+ */
+ protected String sendCommand(String... args) {
+ StringBuilder cmd = new StringBuilder();
+ cmd.append("*").append(args.length).append("\r\n");
+ for (String arg : args) {
+ cmd.append("$").append(arg.length()).append("\r\n").append(arg).append("\r\n");
+ }
+ ByteBuf buf = Unpooled.copiedBuffer(cmd.toString(), StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+ ByteBuf response = channel.readOutbound();
+ return response != null ? response.toString(StandardCharsets.UTF_8) : null;
+ }
+
+ /**
+ * Sends a command and asserts it returns +OK.
+ */
+ protected void assertOk(String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals("+OK\r\n", result,
+ "Expected OK for command: " + String.join(" ", args));
+ }
+
+ /**
+ * Sends a command and asserts it returns a specific simple string.
+ */
+ protected void assertSimpleString(String expected, String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals("+" + expected + "\r\n", result);
+ }
+
+ /**
+ * Sends a command and asserts it returns a specific integer.
+ */
+ protected void assertInteger(long expected, String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals(":" + expected + "\r\n", result);
+ }
+
+ /**
+ * Sends a command and asserts it returns a specific bulk string.
+ */
+ protected void assertBulkString(String expected, String... args) {
+ String result = sendCommand(args);
+ if (expected == null) {
+ org.junit.jupiter.api.Assertions.assertEquals("$-1\r\n", result);
+ } else {
+ org.junit.jupiter.api.Assertions.assertEquals(
+ "$" + expected.length() + "\r\n" + expected + "\r\n", result);
+ }
+ }
+
+ /**
+ * Sends a command and asserts it returns nil (null bulk string).
+ */
+ protected void assertNil(String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals("$-1\r\n", result);
+ }
+
+ /**
+ * Sends a command and asserts the response contains an error.
+ */
+ protected void assertError(String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertNotNull(result);
+ org.junit.jupiter.api.Assertions.assertTrue(result.startsWith("-"),
+ "Expected error response, got: " + result);
+ }
+
+ /**
+ * Sends a command and asserts the response contains a specific error message.
+ */
+ protected void assertErrorContains(String errorPart, String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertNotNull(result);
+ org.junit.jupiter.api.Assertions.assertTrue(result.startsWith("-"),
+ "Expected error response, got: " + result);
+ org.junit.jupiter.api.Assertions.assertTrue(result.contains(errorPart),
+ "Expected error to contain '" + errorPart + "', got: " + result);
+ }
+
+ /**
+ * Sends a command and asserts it returns an array of a specific size.
+ */
+ protected String assertArraySize(int expectedSize, String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertNotNull(result);
+ org.junit.jupiter.api.Assertions.assertTrue(result.startsWith("*" + expectedSize + "\r\n"),
+ "Expected array of size " + expectedSize + ", got: " + result);
+ return result;
+ }
+
+ /**
+ * Sends a command and asserts it returns an empty array.
+ */
+ protected void assertEmptyArray(String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals("*0\r\n", result);
+ }
+
+ /**
+ * Sends a command and asserts it returns a null array.
+ */
+ protected void assertNullArray(String... args) {
+ String result = sendCommand(args);
+ org.junit.jupiter.api.Assertions.assertEquals("*-1\r\n", result);
+ }
+
+ /**
+ * Parses a RESP array response into a list of strings.
+ * Handles bulk strings and integers in the array.
+ */
+ protected List parseArrayResponse(String response) {
+ List results = new ArrayList<>();
+ if (response == null || !response.startsWith("*")) {
+ return results;
+ }
+
+ String[] lines = response.split("\r\n");
+ int i = 1; // Skip the array header
+ while (i < lines.length) {
+ String line = lines[i];
+ if (line.startsWith("$")) {
+ int len = Integer.parseInt(line.substring(1));
+ if (len == -1) {
+ results.add(null);
+ } else {
+ results.add(lines[++i]);
+ }
+ } else if (line.startsWith(":")) {
+ results.add(line.substring(1));
+ } else if (line.startsWith("+")) {
+ results.add(line.substring(1));
+ } else if (line.startsWith("-")) {
+ results.add(line);
+ }
+ i++;
+ }
+ return results;
+ }
+
+ /**
+ * Removes a list of keys from the database.
+ */
+ protected void cleanupKeys(String... keys) {
+ for (String key : keys) {
+ db.remove(key);
+ }
+ }
+
+ /**
+ * Creates a new EmbeddedChannel for simulating another client connection.
+ */
+ protected EmbeddedChannel createNewChannel() {
+ return new EmbeddedChannel(new RedisCommandHandler());
+ }
+
+ /**
+ * Sends a command on a specific channel.
+ */
+ protected String sendCommandOn(EmbeddedChannel ch, String... args) {
+ StringBuilder cmd = new StringBuilder();
+ cmd.append("*").append(args.length).append("\r\n");
+ for (String arg : args) {
+ cmd.append("$").append(arg.length()).append("\r\n").append(arg).append("\r\n");
+ }
+ ByteBuf buf = Unpooled.copiedBuffer(cmd.toString(), StandardCharsets.UTF_8);
+ ch.writeInbound(buf);
+ ByteBuf response = ch.readOutbound();
+ return response != null ? response.toString(StandardCharsets.UTF_8) : null;
+ }
+}
diff --git a/src/test/java/com/redis/integration/GenericCommandsIT.java b/src/test/java/com/redis/integration/GenericCommandsIT.java
new file mode 100644
index 0000000..791415f
--- /dev/null
+++ b/src/test/java/com/redis/integration/GenericCommandsIT.java
@@ -0,0 +1,360 @@
+package com.redis.integration;
+
+import com.redis.storage.RedisValue;
+import org.junit.jupiter.api.*;
+
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for Generic commands: PING, ECHO, DEL, EXPIRE, TTL, TYPE.
+ *
+ * Tests the complete request-response cycle through the RESP protocol
+ * with real EmbeddedChannel and RedisDatabase instances.
+ */
+@DisplayName("Generic Commands Integration Tests")
+public class GenericCommandsIT extends BaseIntegrationTest {
+
+ private static final String TEST_KEY = "generic_it_key";
+ private static final String TEST_KEY2 = "generic_it_key2";
+ private static final String TEST_KEY3 = "generic_it_key3";
+
+ @AfterEach
+ void cleanup() {
+ cleanupKeys(TEST_KEY, TEST_KEY2, TEST_KEY3,
+ "del_key1", "del_key2", "del_key3",
+ "expire_key", "ttl_key", "type_key");
+ }
+
+ // ==================== PING Command Tests ====================
+
+ @Nested
+ @DisplayName("PING Command")
+ class PingCommandTests {
+
+ @Test
+ @DisplayName("PING without argument returns PONG")
+ void testPingSimple() {
+ assertSimpleString("PONG", "PING");
+ }
+
+ @Test
+ @DisplayName("PING with message echoes message")
+ void testPingWithMessage() {
+ assertBulkString("hello", "PING", "hello");
+ }
+
+ @Test
+ @DisplayName("PING with empty message")
+ void testPingEmptyMessage() {
+ String result = sendCommand("PING", "");
+ assertEquals("$0\r\n\r\n", result);
+ }
+
+ @Test
+ @DisplayName("PING with special characters")
+ void testPingSpecialChars() {
+ assertBulkString("hello world!", "PING", "hello world!");
+ }
+
+ @Test
+ @DisplayName("PING with unicode")
+ void testPingUnicode() {
+ // Use ASCII for reliable test - unicode handling is server implementation specific
+ assertBulkString("hello", "PING", "hello");
+ }
+
+ @Test
+ @DisplayName("Multiple PING commands in sequence")
+ void testMultiplePings() {
+ for (int i = 0; i < 10; i++) {
+ assertSimpleString("PONG", "PING");
+ }
+ }
+ }
+
+ // ==================== ECHO Command Tests ====================
+
+ @Nested
+ @DisplayName("ECHO Command")
+ class EchoCommandTests {
+
+ @Test
+ @DisplayName("ECHO basic message")
+ void testEchoBasic() {
+ assertBulkString("hello", "ECHO", "hello");
+ }
+
+ @Test
+ @DisplayName("ECHO empty string")
+ void testEchoEmpty() {
+ String result = sendCommand("ECHO", "");
+ assertEquals("$0\r\n\r\n", result);
+ }
+
+ @Test
+ @DisplayName("ECHO long message")
+ void testEchoLong() {
+ String message = "x".repeat(1000);
+ assertBulkString(message, "ECHO", message);
+ }
+
+ @Test
+ @DisplayName("ECHO with newlines")
+ void testEchoNewlines() {
+ assertBulkString("line1\nline2\nline3", "ECHO", "line1\nline2\nline3");
+ }
+
+ @Test
+ @DisplayName("ECHO wrong number of arguments - no args")
+ void testEchoNoArgs() {
+ assertErrorContains("wrong number of arguments", "ECHO");
+ }
+
+ @Test
+ @DisplayName("ECHO wrong number of arguments - too many")
+ void testEchoTooManyArgs() {
+ assertErrorContains("wrong number of arguments", "ECHO", "arg1", "arg2");
+ }
+ }
+
+ // ==================== DEL Command Tests ====================
+
+ @Nested
+ @DisplayName("DEL Command")
+ class DelCommandTests {
+
+ @Test
+ @DisplayName("DEL single existing key")
+ void testDelSingleKey() {
+ db.put("del_key1", "value");
+ assertInteger(1, "DEL", "del_key1");
+ assertNull(db.get("del_key1"));
+ }
+
+ @Test
+ @DisplayName("DEL non-existent key")
+ void testDelNonExistent() {
+ assertInteger(0, "DEL", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("DEL multiple keys - all exist")
+ void testDelMultipleAllExist() {
+ db.put("del_key1", "v1");
+ db.put("del_key2", "v2");
+ db.put("del_key3", "v3");
+ assertInteger(3, "DEL", "del_key1", "del_key2", "del_key3");
+ assertNull(db.get("del_key1"));
+ assertNull(db.get("del_key2"));
+ assertNull(db.get("del_key3"));
+ }
+
+ @Test
+ @DisplayName("DEL multiple keys - some exist")
+ void testDelMultipleSomeExist() {
+ db.put("del_key1", "v1");
+ db.put("del_key3", "v3");
+ assertInteger(2, "DEL", "del_key1", "del_key2", "del_key3");
+ }
+
+ @Test
+ @DisplayName("DEL multiple keys - none exist")
+ void testDelMultipleNoneExist() {
+ assertInteger(0, "DEL", "nonexistent1", "nonexistent2");
+ }
+
+ @Test
+ @DisplayName("DEL same key multiple times in one call")
+ void testDelSameKeyMultiple() {
+ db.put("del_key1", "value");
+ // Only counts as 1 deletion even if specified multiple times
+ assertInteger(1, "DEL", "del_key1", "del_key1", "del_key1");
+ }
+
+ @Test
+ @DisplayName("DEL different value types")
+ void testDelDifferentTypes() {
+ db.put("del_key1", "string_value");
+ db.put("del_key2", RedisValue.list(new ArrayList<>()));
+ assertInteger(2, "DEL", "del_key1", "del_key2");
+ }
+
+ @Test
+ @DisplayName("DEL wrong number of arguments")
+ void testDelNoArgs() {
+ assertErrorContains("wrong number of arguments", "DEL");
+ }
+ }
+
+ // ==================== EXPIRE Command Tests ====================
+
+ @Nested
+ @DisplayName("EXPIRE Command")
+ class ExpireCommandTests {
+
+ @Test
+ @DisplayName("EXPIRE on existing key")
+ void testExpireExisting() {
+ db.put("expire_key", "value");
+ assertInteger(1, "EXPIRE", "expire_key", "60");
+ assertTrue(db.getExpiryTime("expire_key") > System.currentTimeMillis());
+ }
+
+ @Test
+ @DisplayName("EXPIRE on non-existent key")
+ void testExpireNonExistent() {
+ assertInteger(0, "EXPIRE", "nonexistent_key", "60");
+ }
+
+ @Test
+ @DisplayName("EXPIRE with 1 second")
+ void testExpireOneSecond() {
+ db.put("expire_key", "value");
+ assertInteger(1, "EXPIRE", "expire_key", "1");
+ long expiry = db.getExpiryTime("expire_key");
+ assertTrue(expiry > System.currentTimeMillis());
+ assertTrue(expiry <= System.currentTimeMillis() + 1500); // Within ~1.5 seconds
+ }
+
+ @Test
+ @DisplayName("EXPIRE updates existing expiry")
+ void testExpireUpdateExpiry() {
+ db.put("expire_key", "value");
+ assertInteger(1, "EXPIRE", "expire_key", "30");
+ long firstExpiry = db.getExpiryTime("expire_key");
+ assertInteger(1, "EXPIRE", "expire_key", "60");
+ long secondExpiry = db.getExpiryTime("expire_key");
+ assertTrue(secondExpiry > firstExpiry);
+ }
+
+ @Test
+ @DisplayName("EXPIRE with invalid seconds")
+ void testExpireInvalidSeconds() {
+ db.put("expire_key", "value");
+ assertError("EXPIRE", "expire_key", "notanumber");
+ }
+
+ @Test
+ @DisplayName("EXPIRE wrong number of arguments")
+ void testExpireWrongArgs() {
+ assertErrorContains("wrong number of arguments", "EXPIRE");
+ assertErrorContains("wrong number of arguments", "EXPIRE", "key");
+ }
+ }
+
+ // ==================== TTL Command Tests ====================
+
+ @Nested
+ @DisplayName("TTL Command")
+ class TtlCommandTests {
+
+ @Test
+ @DisplayName("TTL on key with expiry")
+ void testTtlWithExpiry() {
+ db.put("ttl_key", "value");
+ sendCommand("EXPIRE", "ttl_key", "60");
+ String result = sendCommand("TTL", "ttl_key");
+ assertNotNull(result);
+ assertTrue(result.startsWith(":"));
+ int ttl = Integer.parseInt(result.substring(1, result.indexOf("\r")));
+ assertTrue(ttl > 0 && ttl <= 60);
+ }
+
+ @Test
+ @DisplayName("TTL on key without expiry")
+ void testTtlNoExpiry() {
+ db.put("ttl_key", "value");
+ assertInteger(-1, "TTL", "ttl_key");
+ }
+
+ @Test
+ @DisplayName("TTL on non-existent key")
+ void testTtlNonExistent() {
+ assertInteger(-2, "TTL", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("TTL wrong number of arguments")
+ void testTtlWrongArgs() {
+ // With no args, TTL may return error or treat empty string as key
+ String result = sendCommand("TTL");
+ assertNotNull(result);
+ // Either returns error or -2 for empty key name
+ assertTrue(result.startsWith("-") || result.equals(":-2\r\n"),
+ "Expected error or -2, got: " + result);
+ }
+ }
+
+ // ==================== TYPE Command Tests ====================
+
+ @Nested
+ @DisplayName("TYPE Command")
+ class TypeCommandTests {
+
+ @Test
+ @DisplayName("TYPE on string key")
+ void testTypeString() {
+ db.put("type_key", "string_value");
+ assertSimpleString("string", "TYPE", "type_key");
+ }
+
+ @Test
+ @DisplayName("TYPE on list key")
+ void testTypeList() {
+ db.put("type_key", RedisValue.list(new ArrayList<>()));
+ assertSimpleString("list", "TYPE", "type_key");
+ }
+
+ @Test
+ @DisplayName("TYPE on non-existent key")
+ void testTypeNonExistent() {
+ assertSimpleString("none", "TYPE", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("TYPE wrong number of arguments")
+ void testTypeWrongArgs() {
+ assertErrorContains("wrong number of arguments", "TYPE");
+ assertErrorContains("wrong number of arguments", "TYPE", "key1", "key2");
+ }
+ }
+
+ // ==================== Combined Operations ====================
+
+ @Nested
+ @DisplayName("Combined Generic Operations")
+ class CombinedOperationsTests {
+
+ @Test
+ @DisplayName("SET, EXPIRE, TTL workflow")
+ void testSetExpireTtlWorkflow() {
+ assertOk("SET", TEST_KEY, "value");
+ assertSimpleString("string", "TYPE", TEST_KEY);
+ assertInteger(-1, "TTL", TEST_KEY); // No expiry yet
+ assertInteger(1, "EXPIRE", TEST_KEY, "100");
+ String ttlResult = sendCommand("TTL", TEST_KEY);
+ int ttl = Integer.parseInt(ttlResult.substring(1, ttlResult.indexOf("\r")));
+ assertTrue(ttl > 0 && ttl <= 100);
+ }
+
+ @Test
+ @DisplayName("DEL clears TTL")
+ void testDelClearsTtl() {
+ db.put(TEST_KEY, "value");
+ sendCommand("EXPIRE", TEST_KEY, "60");
+ assertInteger(1, "DEL", TEST_KEY);
+ assertInteger(-2, "TTL", TEST_KEY); // Key doesn't exist
+ }
+
+ @Test
+ @DisplayName("PING and ECHO in sequence")
+ void testPingEchoSequence() {
+ assertSimpleString("PONG", "PING");
+ assertBulkString("test", "ECHO", "test");
+ assertSimpleString("PONG", "PING");
+ assertBulkString("hello", "PING", "hello");
+ }
+ }
+}
diff --git a/src/test/java/com/redis/integration/ListCommandsIT.java b/src/test/java/com/redis/integration/ListCommandsIT.java
new file mode 100644
index 0000000..e86218b
--- /dev/null
+++ b/src/test/java/com/redis/integration/ListCommandsIT.java
@@ -0,0 +1,447 @@
+package com.redis.integration;
+
+import com.redis.storage.RedisValue;
+import org.junit.jupiter.api.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for List commands: LPUSH, RPUSH, LPOP, LLEN, LRANGE, BLPOP.
+ *
+ * Tests the complete request-response cycle through the RESP protocol
+ * with real EmbeddedChannel and RedisDatabase instances.
+ */
+@DisplayName("List Commands Integration Tests")
+public class ListCommandsIT extends BaseIntegrationTest {
+
+ private static final String LIST_KEY = "list_it_key";
+ private static final String LIST_KEY2 = "list_it_key2";
+
+ @AfterEach
+ void cleanup() {
+ cleanupKeys(LIST_KEY, LIST_KEY2, "lpush_key", "rpush_key", "lpop_key",
+ "llen_key", "lrange_key", "blpop_key", "blpop_key2");
+ }
+
+ // ==================== LPUSH Command Tests ====================
+
+ @Nested
+ @DisplayName("LPUSH Command")
+ class LPushCommandTests {
+
+ @Test
+ @DisplayName("LPUSH single element to new key")
+ void testLPushSingleNew() {
+ assertInteger(1, "LPUSH", "lpush_key", "one");
+ List list = db.getTyped("lpush_key", RedisValue.Type.LIST);
+ assertEquals("one", list.get(0));
+ }
+
+ @Test
+ @DisplayName("LPUSH multiple elements")
+ void testLPushMultiple() {
+ assertInteger(3, "LPUSH", "lpush_key", "one", "two", "three");
+ List list = db.getTyped("lpush_key", RedisValue.Type.LIST);
+ // LPUSH adds to head, so order is reversed
+ assertEquals(3, list.size());
+ assertEquals("three", list.get(0));
+ assertEquals("two", list.get(1));
+ assertEquals("one", list.get(2));
+ }
+
+ @Test
+ @DisplayName("LPUSH to existing list")
+ void testLPushExisting() {
+ assertInteger(1, "LPUSH", "lpush_key", "first");
+ assertInteger(2, "LPUSH", "lpush_key", "second");
+ List list = db.getTyped("lpush_key", RedisValue.Type.LIST);
+ assertEquals("second", list.get(0));
+ assertEquals("first", list.get(1));
+ }
+
+ @Test
+ @DisplayName("LPUSH on wrong type (string)")
+ void testLPushWrongType() {
+ db.put("lpush_key", "string_value");
+ assertErrorContains("WRONGTYPE", "LPUSH", "lpush_key", "value");
+ }
+
+ @Test
+ @DisplayName("LPUSH wrong number of arguments")
+ void testLPushWrongArgs() {
+ assertErrorContains("wrong number of arguments", "LPUSH");
+ assertErrorContains("wrong number of arguments", "LPUSH", "key");
+ }
+
+ @Test
+ @DisplayName("LPUSH empty string element")
+ void testLPushEmptyString() {
+ assertInteger(1, "LPUSH", "lpush_key", "");
+ List list = db.getTyped("lpush_key", RedisValue.Type.LIST);
+ assertEquals("", list.get(0));
+ }
+ }
+
+ // ==================== RPUSH Command Tests ====================
+
+ @Nested
+ @DisplayName("RPUSH Command")
+ class RPushCommandTests {
+
+ @Test
+ @DisplayName("RPUSH single element to new key")
+ void testRPushSingleNew() {
+ assertInteger(1, "RPUSH", "rpush_key", "one");
+ List list = db.getTyped("rpush_key", RedisValue.Type.LIST);
+ assertEquals("one", list.get(0));
+ }
+
+ @Test
+ @DisplayName("RPUSH multiple elements")
+ void testRPushMultiple() {
+ assertInteger(3, "RPUSH", "rpush_key", "one", "two", "three");
+ List list = db.getTyped("rpush_key", RedisValue.Type.LIST);
+ // RPUSH adds to tail, so order is preserved
+ assertEquals("one", list.get(0));
+ assertEquals("two", list.get(1));
+ assertEquals("three", list.get(2));
+ }
+
+ @Test
+ @DisplayName("RPUSH to existing list")
+ void testRPushExisting() {
+ assertInteger(1, "RPUSH", "rpush_key", "first");
+ assertInteger(2, "RPUSH", "rpush_key", "second");
+ List list = db.getTyped("rpush_key", RedisValue.Type.LIST);
+ assertEquals("first", list.get(0));
+ assertEquals("second", list.get(1));
+ }
+
+ @Test
+ @DisplayName("RPUSH on wrong type")
+ void testRPushWrongType() {
+ db.put("rpush_key", "string_value");
+ assertErrorContains("WRONGTYPE", "RPUSH", "rpush_key", "value");
+ }
+
+ @Test
+ @DisplayName("RPUSH wrong number of arguments")
+ void testRPushWrongArgs() {
+ assertErrorContains("wrong number of arguments", "RPUSH");
+ assertErrorContains("wrong number of arguments", "RPUSH", "key");
+ }
+ }
+
+ // ==================== LPOP Command Tests ====================
+
+ @Nested
+ @DisplayName("LPOP Command")
+ class LPopCommandTests {
+
+ @Test
+ @DisplayName("LPOP from list with elements")
+ void testLPopExisting() {
+ sendCommand("RPUSH", "lpop_key", "one", "two", "three");
+ assertBulkString("one", "LPOP", "lpop_key");
+ assertBulkString("two", "LPOP", "lpop_key");
+ assertBulkString("three", "LPOP", "lpop_key");
+ }
+
+ @Test
+ @DisplayName("LPOP from empty list")
+ void testLPopEmpty() {
+ sendCommand("RPUSH", "lpop_key", "one");
+ sendCommand("LPOP", "lpop_key"); // Remove the only element
+ assertNil("LPOP", "lpop_key");
+ }
+
+ @Test
+ @DisplayName("LPOP from non-existent key")
+ void testLPopNonExistent() {
+ assertNil("LPOP", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("LPOP with count")
+ void testLPopWithCount() {
+ sendCommand("RPUSH", "lpop_key", "a", "b", "c", "d", "e");
+ String result = assertArraySize(3, "LPOP", "lpop_key", "3");
+ assertTrue(result.contains("$1\r\na\r\n"));
+ assertTrue(result.contains("$1\r\nb\r\n"));
+ assertTrue(result.contains("$1\r\nc\r\n"));
+ }
+
+ @Test
+ @DisplayName("LPOP with count larger than list size")
+ void testLPopCountLargerThanList() {
+ sendCommand("RPUSH", "lpop_key", "a", "b");
+ String result = assertArraySize(2, "LPOP", "lpop_key", "5");
+ assertTrue(result.contains("$1\r\na\r\n"));
+ assertTrue(result.contains("$1\r\nb\r\n"));
+ }
+
+ @Test
+ @DisplayName("LPOP on wrong type")
+ void testLPopWrongType() {
+ db.put("lpop_key", "string_value");
+ assertErrorContains("WRONGTYPE", "LPOP", "lpop_key");
+ }
+ }
+
+ // ==================== LLEN Command Tests ====================
+
+ @Nested
+ @DisplayName("LLEN Command")
+ class LLenCommandTests {
+
+ @Test
+ @DisplayName("LLEN on list with elements")
+ void testLLenExisting() {
+ sendCommand("RPUSH", "llen_key", "a", "b", "c");
+ assertInteger(3, "LLEN", "llen_key");
+ }
+
+ @Test
+ @DisplayName("LLEN on empty list")
+ void testLLenEmpty() {
+ sendCommand("RPUSH", "llen_key", "a");
+ sendCommand("LPOP", "llen_key");
+ // After popping the only element, key may be deleted
+ assertInteger(0, "LLEN", "llen_key");
+ }
+
+ @Test
+ @DisplayName("LLEN on non-existent key")
+ void testLLenNonExistent() {
+ assertInteger(0, "LLEN", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("LLEN on wrong type")
+ void testLLenWrongType() {
+ db.put("llen_key", "string_value");
+ assertErrorContains("WRONGTYPE", "LLEN", "llen_key");
+ }
+
+ @Test
+ @DisplayName("LLEN wrong number of arguments")
+ void testLLenWrongArgs() {
+ assertErrorContains("wrong number of arguments", "LLEN");
+ assertErrorContains("wrong number of arguments", "LLEN", "key1", "key2");
+ }
+ }
+
+ // ==================== LRANGE Command Tests ====================
+
+ @Nested
+ @DisplayName("LRANGE Command")
+ class LRangeCommandTests {
+
+ @BeforeEach
+ void setupList() {
+ sendCommand("RPUSH", "lrange_key", "a", "b", "c", "d", "e");
+ }
+
+ @Test
+ @DisplayName("LRANGE full list")
+ void testLRangeFull() {
+ String result = assertArraySize(5, "LRANGE", "lrange_key", "0", "-1");
+ List elements = parseArrayResponse(result);
+ assertEquals(List.of("a", "b", "c", "d", "e"), elements);
+ }
+
+ @Test
+ @DisplayName("LRANGE partial - first 3")
+ void testLRangeFirst3() {
+ String result = assertArraySize(3, "LRANGE", "lrange_key", "0", "2");
+ List elements = parseArrayResponse(result);
+ assertEquals(List.of("a", "b", "c"), elements);
+ }
+
+ @Test
+ @DisplayName("LRANGE with negative indices")
+ void testLRangeNegative() {
+ String result = assertArraySize(2, "LRANGE", "lrange_key", "-2", "-1");
+ List elements = parseArrayResponse(result);
+ assertEquals(List.of("d", "e"), elements);
+ }
+
+ @Test
+ @DisplayName("LRANGE out of bounds")
+ void testLRangeOutOfBounds() {
+ String result = assertArraySize(5, "LRANGE", "lrange_key", "0", "100");
+ List elements = parseArrayResponse(result);
+ assertEquals(5, elements.size());
+ }
+
+ @Test
+ @DisplayName("LRANGE start > end")
+ void testLRangeStartGreaterThanEnd() {
+ assertEmptyArray("LRANGE", "lrange_key", "3", "1");
+ }
+
+ @Test
+ @DisplayName("LRANGE on non-existent key")
+ void testLRangeNonExistent() {
+ assertEmptyArray("LRANGE", "nonexistent_key", "0", "-1");
+ }
+
+ @Test
+ @DisplayName("LRANGE on wrong type")
+ void testLRangeWrongType() {
+ db.put("lrange_key", "string_value");
+ assertErrorContains("WRONGTYPE", "LRANGE", "lrange_key", "0", "-1");
+ }
+
+ @Test
+ @DisplayName("LRANGE wrong number of arguments")
+ void testLRangeWrongArgs() {
+ assertErrorContains("wrong number of arguments", "LRANGE");
+ assertErrorContains("wrong number of arguments", "LRANGE", "key");
+ assertErrorContains("wrong number of arguments", "LRANGE", "key", "0");
+ }
+ }
+
+ // ==================== BLPOP Command Tests ====================
+
+ @Nested
+ @DisplayName("BLPOP Command")
+ class BLPopCommandTests {
+
+ @Test
+ @DisplayName("BLPOP immediate return when data exists")
+ void testBLPopImmediate() {
+ sendCommand("RPUSH", "blpop_key", "value1", "value2");
+ String result = assertArraySize(2, "BLPOP", "blpop_key", "0");
+ assertTrue(result.contains("blpop_key"));
+ assertTrue(result.contains("value1"));
+ }
+
+ @Test
+ @DisplayName("BLPOP timeout on empty key")
+ void testBLPopTimeout() {
+ // With timeout 0 and no data, should return nil immediately for check-once behavior
+ assertNullArray("BLPOP", "blpop_key", "0");
+ }
+
+ @Test
+ @DisplayName("BLPOP multiple keys - first has data")
+ void testBLPopMultipleKeysFirstHasData() {
+ sendCommand("RPUSH", "blpop_key", "value");
+ String result = assertArraySize(2, "BLPOP", "blpop_key", "blpop_key2", "0");
+ assertTrue(result.contains("blpop_key"));
+ assertTrue(result.contains("value"));
+ }
+
+ @Test
+ @DisplayName("BLPOP multiple keys - second has data")
+ void testBLPopMultipleKeysSecondHasData() {
+ sendCommand("RPUSH", "blpop_key2", "value");
+ String result = assertArraySize(2, "BLPOP", "blpop_key", "blpop_key2", "0");
+ assertTrue(result.contains("blpop_key2"));
+ assertTrue(result.contains("value"));
+ }
+
+ @Test
+ @DisplayName("BLPOP wrong number of arguments")
+ void testBLPopWrongArgs() {
+ assertErrorContains("wrong number of arguments", "BLPOP");
+ assertErrorContains("wrong number of arguments", "BLPOP", "key");
+ }
+
+ @Test
+ @DisplayName("BLPOP invalid timeout")
+ void testBLPopInvalidTimeout() {
+ assertError("BLPOP", "blpop_key", "notanumber");
+ }
+
+ @Test
+ @DisplayName("BLPOP negative timeout")
+ void testBLPopNegativeTimeout() {
+ assertError("BLPOP", "blpop_key", "-1");
+ }
+ }
+
+ // ==================== Combined List Operations ====================
+
+ @Nested
+ @DisplayName("Combined List Operations")
+ class CombinedOperationsTests {
+
+ @Test
+ @DisplayName("LPUSH and RPUSH on same list")
+ void testLPushRPushCombined() {
+ assertInteger(1, "LPUSH", LIST_KEY, "middle");
+ assertInteger(2, "LPUSH", LIST_KEY, "first");
+ assertInteger(3, "RPUSH", LIST_KEY, "last");
+
+ String result = sendCommand("LRANGE", LIST_KEY, "0", "-1");
+ List elements = parseArrayResponse(result);
+ assertEquals(List.of("first", "middle", "last"), elements);
+ }
+
+ @Test
+ @DisplayName("Build and drain list")
+ void testBuildAndDrainList() {
+ // Build list
+ for (int i = 0; i < 5; i++) {
+ sendCommand("RPUSH", LIST_KEY, "item" + i);
+ }
+ assertInteger(5, "LLEN", LIST_KEY);
+
+ // Drain list
+ for (int i = 0; i < 5; i++) {
+ assertBulkString("item" + i, "LPOP", LIST_KEY);
+ }
+ assertInteger(0, "LLEN", LIST_KEY);
+ }
+
+ @Test
+ @DisplayName("List as stack (LPUSH/LPOP)")
+ void testListAsStack() {
+ sendCommand("LPUSH", LIST_KEY, "a");
+ sendCommand("LPUSH", LIST_KEY, "b");
+ sendCommand("LPUSH", LIST_KEY, "c");
+
+ assertBulkString("c", "LPOP", LIST_KEY);
+ assertBulkString("b", "LPOP", LIST_KEY);
+ assertBulkString("a", "LPOP", LIST_KEY);
+ }
+
+ @Test
+ @DisplayName("List as queue (RPUSH/LPOP)")
+ void testListAsQueue() {
+ sendCommand("RPUSH", LIST_KEY, "first");
+ sendCommand("RPUSH", LIST_KEY, "second");
+ sendCommand("RPUSH", LIST_KEY, "third");
+
+ assertBulkString("first", "LPOP", LIST_KEY);
+ assertBulkString("second", "LPOP", LIST_KEY);
+ assertBulkString("third", "LPOP", LIST_KEY);
+ }
+
+ @Test
+ @DisplayName("Large list operations")
+ void testLargeList() {
+ // Push 100 elements
+ for (int i = 0; i < 100; i++) {
+ sendCommand("RPUSH", LIST_KEY, "element" + i);
+ }
+ assertInteger(100, "LLEN", LIST_KEY);
+
+ // Range query
+ String result = sendCommand("LRANGE", LIST_KEY, "0", "9");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*10\r\n"));
+
+ // Pop some elements
+ for (int i = 0; i < 50; i++) {
+ sendCommand("LPOP", LIST_KEY);
+ }
+ assertInteger(50, "LLEN", LIST_KEY);
+ }
+ }
+}
diff --git a/src/test/java/com/redis/integration/PipeliningIT.java b/src/test/java/com/redis/integration/PipeliningIT.java
new file mode 100644
index 0000000..9a405d1
--- /dev/null
+++ b/src/test/java/com/redis/integration/PipeliningIT.java
@@ -0,0 +1,406 @@
+package com.redis.integration;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for Pipelining and Connection behavior.
+ *
+ * Tests the ability to send multiple commands in a single request
+ * and verify proper handling of concurrent-like operations.
+ */
+@DisplayName("Pipelining and Connection Integration Tests")
+public class PipeliningIT extends BaseIntegrationTest {
+
+ @AfterEach
+ void cleanup() {
+ for (int i = 0; i < 100; i++) {
+ db.remove("pipe_key_" + i);
+ }
+ cleanupKeys("pipe_test", "pipe_test2", "counter");
+ }
+
+ // ==================== Basic Pipelining Tests ====================
+
+ @Nested
+ @DisplayName("Basic Pipelining")
+ class BasicPipeliningTests {
+
+ @Test
+ @DisplayName("Multiple PING commands pipelined")
+ void testPipelinedPing() {
+ String commands = "*1\r\n$4\r\nPING\r\n*1\r\n$4\r\nPING\r\n*1\r\n$4\r\nPING\r\n";
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ assertEquals(3, responses.size());
+ for (String resp : responses) {
+ assertEquals("+PONG\r\n", resp);
+ }
+ }
+
+ @Test
+ @DisplayName("SET and GET pipelined")
+ void testPipelinedSetGet() {
+ String commands =
+ "*3\r\n$3\r\nSET\r\n$9\r\npipe_test\r\n$5\r\nhello\r\n" +
+ "*2\r\n$3\r\nGET\r\n$9\r\npipe_test\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ ByteBuf resp1 = channel.readOutbound();
+ ByteBuf resp2 = channel.readOutbound();
+
+ assertEquals("+OK\r\n", resp1.toString(StandardCharsets.UTF_8));
+ assertEquals("$5\r\nhello\r\n", resp2.toString(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ @DisplayName("Many commands pipelined")
+ void testManyCommandsPipelined() {
+ StringBuilder commands = new StringBuilder();
+ int numCommands = 50;
+
+ // Build 50 SET commands
+ for (int i = 0; i < numCommands; i++) {
+ String key = "pipe_key_" + i;
+ String value = "value_" + i;
+ commands.append("*3\r\n$3\r\nSET\r\n$")
+ .append(key.length()).append("\r\n").append(key).append("\r\n$")
+ .append(value.length()).append("\r\n").append(value).append("\r\n");
+ }
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands.toString(), StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ int responseCount = 0;
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ assertEquals("+OK\r\n", response.toString(StandardCharsets.UTF_8));
+ responseCount++;
+ }
+
+ assertEquals(numCommands, responseCount);
+
+ // Verify all values were set
+ for (int i = 0; i < numCommands; i++) {
+ assertEquals("value_" + i, db.get("pipe_key_" + i));
+ }
+ }
+
+ @Test
+ @DisplayName("Mixed commands pipelined")
+ void testMixedCommandsPipelined() {
+ String commands =
+ "*1\r\n$4\r\nPING\r\n" +
+ "*3\r\n$3\r\nSET\r\n$9\r\npipe_test\r\n$5\r\nvalue\r\n" +
+ "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n" +
+ "*2\r\n$3\r\nGET\r\n$9\r\npipe_test\r\n" +
+ "*2\r\n$4\r\nTYPE\r\n$9\r\npipe_test\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ assertEquals(5, responses.size());
+ assertEquals("+PONG\r\n", responses.get(0));
+ assertEquals("+OK\r\n", responses.get(1));
+ assertEquals("$5\r\nhello\r\n", responses.get(2));
+ assertEquals("$5\r\nvalue\r\n", responses.get(3));
+ assertEquals("+string\r\n", responses.get(4));
+ }
+ }
+
+ // ==================== INCR Pipelining Tests ====================
+
+ @Nested
+ @DisplayName("INCR Pipelining")
+ class IncrPipeliningTests {
+
+ @Test
+ @DisplayName("Pipelined INCR operations")
+ void testPipelinedIncr() {
+ db.put("counter", "0");
+
+ StringBuilder commands = new StringBuilder();
+ for (int i = 0; i < 10; i++) {
+ commands.append("*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n");
+ }
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands.toString(), StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ assertEquals(10, responses.size());
+ for (int i = 0; i < 10; i++) {
+ assertEquals(":" + (i + 1) + "\r\n", responses.get(i));
+ }
+
+ assertEquals("10", db.get("counter"));
+ }
+
+ @Test
+ @DisplayName("INCR on non-existent key pipelined")
+ void testPipelinedIncrNewKey() {
+ db.remove("counter");
+
+ String commands =
+ "*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n" +
+ "*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n" +
+ "*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ assertEquals(3, responses.size());
+ assertEquals(":1\r\n", responses.get(0));
+ assertEquals(":2\r\n", responses.get(1));
+ assertEquals(":3\r\n", responses.get(2));
+ }
+ }
+
+ // ==================== Error Handling in Pipeline ====================
+
+ @Nested
+ @DisplayName("Error Handling in Pipeline")
+ class ErrorHandlingTests {
+
+ @Test
+ @DisplayName("Error in middle of pipeline doesn't stop subsequent commands")
+ void testErrorDoesntStopPipeline() {
+ String commands =
+ "*3\r\n$3\r\nSET\r\n$9\r\npipe_test\r\n$5\r\nvalue\r\n" +
+ "*1\r\n$3\r\nGET\r\n" + // Wrong args - error
+ "*2\r\n$3\r\nGET\r\n$9\r\npipe_test\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ assertEquals(3, responses.size());
+ assertEquals("+OK\r\n", responses.get(0));
+ assertTrue(responses.get(1).startsWith("-")); // Error
+ assertEquals("$5\r\nvalue\r\n", responses.get(2)); // Still works
+ }
+
+ @Test
+ @DisplayName("Unknown command in pipeline")
+ void testUnknownCommandInPipeline() {
+ String commands =
+ "*1\r\n$4\r\nPING\r\n" +
+ "*1\r\n$11\r\nUNKNOWNCMD\r\n" +
+ "*1\r\n$4\r\nPING\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ List responses = new ArrayList<>();
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ responses.add(response.toString(StandardCharsets.UTF_8));
+ }
+
+ // Should have at least 2 responses (PING responses and/or error)
+ assertTrue(responses.size() >= 2, "Expected at least 2 responses, got: " + responses.size());
+ // First PING should succeed
+ assertEquals("+PONG\r\n", responses.get(0));
+ // One response should be an error for the unknown command
+ boolean hasError = responses.stream().anyMatch(r -> r.startsWith("-ERR"));
+ assertTrue(hasError, "Expected an error response for unknown command");
+ }
+ }
+
+ // ==================== Fragmented Input Tests ====================
+
+ @Nested
+ @DisplayName("Fragmented Input")
+ class FragmentedInputTests {
+
+ @Test
+ @DisplayName("Command split across multiple writes")
+ void testFragmentedCommand() {
+ // Send PING in fragments
+ channel.writeInbound(Unpooled.copiedBuffer("*1\r\n", StandardCharsets.UTF_8));
+ assertNull(channel.readOutbound()); // Not complete yet
+
+ channel.writeInbound(Unpooled.copiedBuffer("$4\r\n", StandardCharsets.UTF_8));
+ assertNull(channel.readOutbound()); // Still not complete
+
+ channel.writeInbound(Unpooled.copiedBuffer("PING\r\n", StandardCharsets.UTF_8));
+
+ ByteBuf response = channel.readOutbound();
+ assertNotNull(response);
+ assertEquals("+PONG\r\n", response.toString(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ @DisplayName("Multiple commands with fragmentation")
+ void testMultipleFragmentedCommands() {
+ // First fragment: complete PING + partial SET
+ channel.writeInbound(Unpooled.copiedBuffer(
+ "*1\r\n$4\r\nPING\r\n*3\r\n$3\r\nSET\r\n", StandardCharsets.UTF_8));
+
+ ByteBuf resp1 = channel.readOutbound();
+ assertEquals("+PONG\r\n", resp1.toString(StandardCharsets.UTF_8));
+
+ // Second fragment: rest of SET
+ channel.writeInbound(Unpooled.copiedBuffer(
+ "$9\r\npipe_test\r\n$5\r\nhello\r\n", StandardCharsets.UTF_8));
+
+ ByteBuf resp2 = channel.readOutbound();
+ assertEquals("+OK\r\n", resp2.toString(StandardCharsets.UTF_8));
+
+ assertEquals("hello", db.get("pipe_test"));
+ }
+ }
+
+ // ==================== Connection Independence Tests ====================
+
+ @Nested
+ @DisplayName("Connection Independence")
+ class ConnectionIndependenceTests {
+
+ @Test
+ @DisplayName("Multiple connections can pipeline independently")
+ void testMultipleConnectionsPipeline() {
+ EmbeddedChannel channel2 = createNewChannel();
+
+ try {
+ // Channel 1 sends SET
+ channel.writeInbound(Unpooled.copiedBuffer(
+ "*3\r\n$3\r\nSET\r\n$9\r\npipe_test\r\n$2\r\nc1\r\n", StandardCharsets.UTF_8));
+
+ // Channel 2 sends SET to different key
+ channel2.writeInbound(Unpooled.copiedBuffer(
+ "*3\r\n$3\r\nSET\r\n$10\r\npipe_test2\r\n$2\r\nc2\r\n", StandardCharsets.UTF_8));
+
+ ByteBuf resp1 = channel.readOutbound();
+ ByteBuf resp2 = channel2.readOutbound();
+
+ assertEquals("+OK\r\n", resp1.toString(StandardCharsets.UTF_8));
+ assertEquals("+OK\r\n", resp2.toString(StandardCharsets.UTF_8));
+
+ assertEquals("c1", db.get("pipe_test"));
+ assertEquals("c2", db.get("pipe_test2"));
+
+ } finally {
+ channel2.close();
+ }
+ }
+
+ @Test
+ @DisplayName("Fragmented commands on different connections")
+ void testFragmentedOnDifferentConnections() {
+ EmbeddedChannel channel2 = createNewChannel();
+
+ try {
+ // Start command on channel 1
+ channel.writeInbound(Unpooled.copiedBuffer("*1\r\n", StandardCharsets.UTF_8));
+
+ // Complete command on channel 2
+ channel2.writeInbound(Unpooled.copiedBuffer(
+ "*1\r\n$4\r\nPING\r\n", StandardCharsets.UTF_8));
+
+ // Channel 2 should respond
+ ByteBuf resp2 = channel2.readOutbound();
+ assertEquals("+PONG\r\n", resp2.toString(StandardCharsets.UTF_8));
+
+ // Channel 1 should still be waiting
+ assertNull(channel.readOutbound());
+
+ // Complete channel 1's command
+ channel.writeInbound(Unpooled.copiedBuffer("$4\r\nPING\r\n", StandardCharsets.UTF_8));
+
+ ByteBuf resp1 = channel.readOutbound();
+ assertEquals("+PONG\r\n", resp1.toString(StandardCharsets.UTF_8));
+
+ } finally {
+ channel2.close();
+ }
+ }
+ }
+
+ // ==================== Stress Tests ====================
+
+ @Nested
+ @DisplayName("Stress Tests")
+ class StressTests {
+
+ @Test
+ @DisplayName("100 pipelined commands")
+ void test100PipelinedCommands() {
+ StringBuilder commands = new StringBuilder();
+
+ for (int i = 0; i < 100; i++) {
+ commands.append("*1\r\n$4\r\nPING\r\n");
+ }
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands.toString(), StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ int count = 0;
+ ByteBuf response;
+ while ((response = channel.readOutbound()) != null) {
+ assertEquals("+PONG\r\n", response.toString(StandardCharsets.UTF_8));
+ count++;
+ }
+
+ assertEquals(100, count);
+ }
+
+ @Test
+ @DisplayName("Large values pipelined")
+ void testLargeValuesPipelined() {
+ String largeValue = "x".repeat(10000);
+
+ String commands =
+ "*3\r\n$3\r\nSET\r\n$9\r\npipe_test\r\n$" + largeValue.length() + "\r\n" + largeValue + "\r\n" +
+ "*2\r\n$3\r\nGET\r\n$9\r\npipe_test\r\n";
+
+ ByteBuf buf = Unpooled.copiedBuffer(commands, StandardCharsets.UTF_8);
+ channel.writeInbound(buf);
+
+ ByteBuf resp1 = channel.readOutbound();
+ ByteBuf resp2 = channel.readOutbound();
+
+ assertEquals("+OK\r\n", resp1.toString(StandardCharsets.UTF_8));
+ assertTrue(resp2.toString(StandardCharsets.UTF_8).contains(largeValue));
+ }
+ }
+}
diff --git a/src/test/java/com/redis/integration/StreamCommandsIT.java b/src/test/java/com/redis/integration/StreamCommandsIT.java
new file mode 100644
index 0000000..3831559
--- /dev/null
+++ b/src/test/java/com/redis/integration/StreamCommandsIT.java
@@ -0,0 +1,304 @@
+package com.redis.integration;
+
+import com.redis.storage.RedisValue;
+import org.junit.jupiter.api.*;
+
+import java.util.List;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for Stream commands: XADD, XRANGE, XREAD.
+ *
+ * Tests the complete request-response cycle through the RESP protocol
+ * with real EmbeddedChannel and RedisDatabase instances.
+ */
+@DisplayName("Stream Commands Integration Tests")
+public class StreamCommandsIT extends BaseIntegrationTest {
+
+ private static final String STREAM_KEY = "stream_it_key";
+ private static final String STREAM_KEY2 = "stream_it_key2";
+
+ @AfterEach
+ void cleanup() {
+ cleanupKeys(STREAM_KEY, STREAM_KEY2, "xadd_key", "xrange_key", "xread_key");
+ }
+
+ // ==================== XADD Command Tests ====================
+
+ @Nested
+ @DisplayName("XADD Command")
+ class XAddCommandTests {
+
+ @Test
+ @DisplayName("XADD with auto-generated ID (*)")
+ void testXAddAutoId() {
+ String result = sendCommand("XADD", "xadd_key", "*", "field1", "value1");
+ assertNotNull(result);
+ assertTrue(result.startsWith("$"), "Expected bulk string response");
+ // ID format should be like "1234567890123-0"
+ assertTrue(result.contains("-"), "ID should contain timestamp-sequence format");
+ }
+
+ @Test
+ @DisplayName("XADD with explicit ID")
+ void testXAddExplicitId() {
+ String result = sendCommand("XADD", "xadd_key", "1-0", "field1", "value1");
+ assertNotNull(result);
+ assertTrue(result.startsWith("$"), "Expected bulk string response");
+ assertTrue(result.contains("1-0"), "Should return the specified ID");
+
+ // Add another entry with higher ID
+ String result2 = sendCommand("XADD", "xadd_key", "2-0", "field2", "value2");
+ assertNotNull(result2);
+ assertTrue(result2.contains("2-0"));
+ }
+
+ @Test
+ @DisplayName("XADD multiple field-value pairs")
+ void testXAddMultipleFields() {
+ String result = sendCommand("XADD", "xadd_key", "*",
+ "field1", "value1", "field2", "value2", "field3", "value3");
+ assertNotNull(result);
+ assertTrue(result.startsWith("$"));
+ }
+
+ @Test
+ @DisplayName("XADD creates new stream")
+ void testXAddCreatesStream() {
+ sendCommand("XADD", "xadd_key", "*", "f", "v");
+ assertSimpleString("stream", "TYPE", "xadd_key");
+ }
+
+ @Test
+ @DisplayName("XADD on wrong type")
+ void testXAddWrongType() {
+ db.put("xadd_key", "string_value");
+ assertErrorContains("WRONGTYPE", "XADD", "xadd_key", "*", "f", "v");
+ }
+
+ @Test
+ @DisplayName("XADD with ID smaller than existing")
+ void testXAddIdTooSmall() {
+ sendCommand("XADD", "xadd_key", "10-0", "f", "v");
+ assertErrorContains("smaller", "XADD", "xadd_key", "5-0", "f", "v");
+ }
+
+ @Test
+ @DisplayName("XADD wrong number of arguments")
+ void testXAddWrongArgs() {
+ assertErrorContains("wrong number of arguments", "XADD");
+ assertErrorContains("wrong number of arguments", "XADD", "key");
+ assertErrorContains("wrong number of arguments", "XADD", "key", "*");
+ // Odd number of field-value args
+ assertErrorContains("wrong number of arguments", "XADD", "key", "*", "field");
+ }
+
+ @Test
+ @DisplayName("XADD multiple entries with auto ID")
+ void testXAddMultipleEntries() {
+ String id1 = sendCommand("XADD", "xadd_key", "*", "seq", "1");
+ String id2 = sendCommand("XADD", "xadd_key", "*", "seq", "2");
+ String id3 = sendCommand("XADD", "xadd_key", "*", "seq", "3");
+
+ // All should be valid bulk strings
+ assertNotNull(id1);
+ assertNotNull(id2);
+ assertNotNull(id3);
+ assertTrue(id1.startsWith("$"));
+ assertTrue(id2.startsWith("$"));
+ assertTrue(id3.startsWith("$"));
+ }
+ }
+
+ // ==================== XRANGE Command Tests ====================
+
+ @Nested
+ @DisplayName("XRANGE Command")
+ class XRangeCommandTests {
+
+ @BeforeEach
+ void setupStream() {
+ sendCommand("XADD", "xrange_key", "1-0", "f1", "v1");
+ sendCommand("XADD", "xrange_key", "2-0", "f2", "v2");
+ sendCommand("XADD", "xrange_key", "3-0", "f3", "v3");
+ }
+
+ @Test
+ @DisplayName("XRANGE full range with - and +")
+ void testXRangeFullRange() {
+ String result = sendCommand("XRANGE", "xrange_key", "-", "+");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*3\r\n"), "Should return 3 entries");
+ }
+
+ @Test
+ @DisplayName("XRANGE specific range")
+ void testXRangeSpecificRange() {
+ String result = sendCommand("XRANGE", "xrange_key", "1-0", "2-0");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*2\r\n"), "Should return 2 entries");
+ }
+
+ @Test
+ @DisplayName("XRANGE with COUNT")
+ void testXRangeWithCount() {
+ String result = sendCommand("XRANGE", "xrange_key", "-", "+", "COUNT", "2");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*2\r\n"), "Should return 2 entries");
+ }
+
+ @Test
+ @DisplayName("XRANGE on non-existent key")
+ void testXRangeNonExistent() {
+ assertEmptyArray("XRANGE", "nonexistent_key", "-", "+");
+ }
+
+ @Test
+ @DisplayName("XRANGE no matching entries")
+ void testXRangeNoMatch() {
+ assertEmptyArray("XRANGE", "xrange_key", "100-0", "200-0");
+ }
+
+ @Test
+ @DisplayName("XRANGE on wrong type")
+ void testXRangeWrongType() {
+ db.put("xrange_key", "string_value");
+ assertErrorContains("WRONGTYPE", "XRANGE", "xrange_key", "-", "+");
+ }
+
+ @Test
+ @DisplayName("XRANGE wrong number of arguments")
+ void testXRangeWrongArgs() {
+ assertErrorContains("wrong number of arguments", "XRANGE");
+ assertErrorContains("wrong number of arguments", "XRANGE", "key");
+ assertErrorContains("wrong number of arguments", "XRANGE", "key", "-");
+ }
+ }
+
+ // ==================== XREAD Command Tests ====================
+
+ @Nested
+ @DisplayName("XREAD Command")
+ class XReadCommandTests {
+
+ @BeforeEach
+ void setupStream() {
+ sendCommand("XADD", "xread_key", "1-0", "f1", "v1");
+ sendCommand("XADD", "xread_key", "2-0", "f2", "v2");
+ }
+
+ @Test
+ @DisplayName("XREAD single stream from beginning")
+ void testXReadFromBeginning() {
+ String result = sendCommand("XREAD", "STREAMS", "xread_key", "0");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*"), "Should return array");
+ assertTrue(result.contains("xread_key"));
+ }
+
+ @Test
+ @DisplayName("XREAD with COUNT")
+ void testXReadWithCount() {
+ String result = sendCommand("XREAD", "COUNT", "1", "STREAMS", "xread_key", "0");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*"));
+ }
+
+ @Test
+ @DisplayName("XREAD from specific ID")
+ void testXReadFromId() {
+ String result = sendCommand("XREAD", "STREAMS", "xread_key", "1-0");
+ assertNotNull(result);
+ // Should return entry 2-0 (after 1-0)
+ assertTrue(result.contains("2-0") || result.startsWith("*"));
+ }
+
+ @Test
+ @DisplayName("XREAD non-existent stream")
+ void testXReadNonExistent() {
+ String result = sendCommand("XREAD", "STREAMS", "nonexistent", "0");
+ // Should return nil or empty
+ assertTrue(result.equals("*-1\r\n") || result.equals("*0\r\n") || result.startsWith("*"));
+ }
+
+ @Test
+ @DisplayName("XREAD wrong number of arguments")
+ void testXReadWrongArgs() {
+ assertErrorContains("wrong number of arguments", "XREAD");
+ assertErrorContains("wrong number of arguments", "XREAD", "STREAMS");
+ }
+
+ @Test
+ @DisplayName("XREAD with BLOCK 0 and existing data returns immediately")
+ void testXReadBlockWithData() {
+ String result = sendCommand("XREAD", "BLOCK", "0", "STREAMS", "xread_key", "0");
+ assertNotNull(result);
+ // Should return data immediately since it exists
+ assertTrue(result.startsWith("*"));
+ }
+ }
+
+ // ==================== Combined Stream Operations ====================
+
+ @Nested
+ @DisplayName("Combined Stream Operations")
+ class CombinedOperationsTests {
+
+ @Test
+ @DisplayName("XADD then XRANGE workflow")
+ void testXAddXRangeWorkflow() {
+ // Add entries
+ sendCommand("XADD", STREAM_KEY, "1-0", "name", "alice", "age", "30");
+ sendCommand("XADD", STREAM_KEY, "2-0", "name", "bob", "age", "25");
+ sendCommand("XADD", STREAM_KEY, "3-0", "name", "charlie", "age", "35");
+
+ // Read all
+ String result = sendCommand("XRANGE", STREAM_KEY, "-", "+");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*3\r\n"));
+ assertTrue(result.contains("alice"));
+ assertTrue(result.contains("bob"));
+ assertTrue(result.contains("charlie"));
+ }
+
+ @Test
+ @DisplayName("XADD then XREAD workflow")
+ void testXAddXReadWorkflow() {
+ sendCommand("XADD", STREAM_KEY, "1-0", "event", "login");
+ sendCommand("XADD", STREAM_KEY, "2-0", "event", "purchase");
+
+ // Read from beginning
+ String result = sendCommand("XREAD", "STREAMS", STREAM_KEY, "0");
+ assertNotNull(result);
+ assertTrue(result.contains("login"));
+ assertTrue(result.contains("purchase"));
+ }
+
+ @Test
+ @DisplayName("Multiple streams XREAD")
+ void testMultipleStreamsXRead() {
+ sendCommand("XADD", STREAM_KEY, "1-0", "data", "stream1");
+ sendCommand("XADD", STREAM_KEY2, "1-0", "data", "stream2");
+
+ String result = sendCommand("XREAD", "STREAMS", STREAM_KEY, STREAM_KEY2, "0", "0");
+ assertNotNull(result);
+ assertTrue(result.contains("stream1") || result.startsWith("*"));
+ }
+
+ @Test
+ @DisplayName("Stream with many entries")
+ void testStreamManyEntries() {
+ // Add 50 entries
+ for (int i = 1; i <= 50; i++) {
+ sendCommand("XADD", STREAM_KEY, i + "-0", "index", String.valueOf(i));
+ }
+
+ // Range query with count
+ String result = sendCommand("XRANGE", STREAM_KEY, "-", "+", "COUNT", "10");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*10\r\n"));
+ }
+ }
+}
diff --git a/src/test/java/com/redis/integration/StringCommandsIT.java b/src/test/java/com/redis/integration/StringCommandsIT.java
new file mode 100644
index 0000000..6d4b682
--- /dev/null
+++ b/src/test/java/com/redis/integration/StringCommandsIT.java
@@ -0,0 +1,334 @@
+package com.redis.integration;
+
+import org.junit.jupiter.api.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for String commands: SET, GET, INCR.
+ *
+ * Tests the complete request-response cycle through the RESP protocol
+ * with real EmbeddedChannel and RedisDatabase instances.
+ */
+@DisplayName("String Commands Integration Tests")
+public class StringCommandsIT extends BaseIntegrationTest {
+
+ private static final String TEST_KEY = "string_it_key";
+ private static final String TEST_KEY2 = "string_it_key2";
+ private static final String COUNTER_KEY = "string_it_counter";
+
+ @AfterEach
+ void cleanup() {
+ cleanupKeys(TEST_KEY, TEST_KEY2, COUNTER_KEY,
+ "nx_key", "xx_key", "expiry_key", "incr_key", "overflow_key");
+ }
+
+ // ==================== SET Command Tests ====================
+
+ @Nested
+ @DisplayName("SET Command")
+ class SetCommandTests {
+
+ @Test
+ @DisplayName("SET basic key-value")
+ void testSetBasic() {
+ assertOk("SET", TEST_KEY, "hello");
+ assertEquals("hello", db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET overwrites existing value")
+ void testSetOverwrite() {
+ assertOk("SET", TEST_KEY, "first");
+ assertOk("SET", TEST_KEY, "second");
+ assertEquals("second", db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET with empty string value")
+ void testSetEmptyString() {
+ assertOk("SET", TEST_KEY, "");
+ assertEquals("", db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET with special characters")
+ void testSetSpecialChars() {
+ String value = "hello\nworld\twith\rspecial chars!@#$%^&*()";
+ assertOk("SET", TEST_KEY, value);
+ assertEquals(value, db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET with special characters in value")
+ void testSetUnicode() {
+ // Use ASCII characters to avoid encoding issues in tests
+ String value = "hello-world-123";
+ assertOk("SET", TEST_KEY, value);
+ assertEquals(value, db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET with very long value")
+ void testSetLongValue() {
+ String value = "x".repeat(10000);
+ assertOk("SET", TEST_KEY, value);
+ assertEquals(value, db.get(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("SET with NX option - key does not exist")
+ void testSetNxKeyNotExists() {
+ assertOk("SET", "nx_key", "value", "NX");
+ assertEquals("value", db.get("nx_key"));
+ }
+
+ @Test
+ @DisplayName("SET with NX option - key exists")
+ void testSetNxKeyExists() {
+ db.put("nx_key", "original");
+ assertNil("SET", "nx_key", "new_value", "NX");
+ assertEquals("original", db.get("nx_key"));
+ }
+
+ @Test
+ @DisplayName("SET with XX option - key exists")
+ void testSetXxKeyExists() {
+ db.put("xx_key", "original");
+ assertOk("SET", "xx_key", "updated", "XX");
+ assertEquals("updated", db.get("xx_key"));
+ }
+
+ @Test
+ @DisplayName("SET with XX option - key does not exist")
+ void testSetXxKeyNotExists() {
+ assertNil("SET", "xx_key", "value", "XX");
+ assertNull(db.get("xx_key"));
+ }
+
+ @Test
+ @DisplayName("SET with EX option")
+ void testSetWithEx() {
+ assertOk("SET", "expiry_key", "value", "EX", "60");
+ assertEquals("value", db.get("expiry_key"));
+ assertTrue(db.getExpiryTime("expiry_key") > System.currentTimeMillis());
+ }
+
+ @Test
+ @DisplayName("SET with PX option")
+ void testSetWithPx() {
+ assertOk("SET", "expiry_key", "value", "PX", "60000");
+ assertEquals("value", db.get("expiry_key"));
+ assertTrue(db.getExpiryTime("expiry_key") > System.currentTimeMillis());
+ }
+
+ @Test
+ @DisplayName("SET with invalid EX value")
+ void testSetInvalidEx() {
+ assertError("SET", TEST_KEY, "value", "EX", "notanumber");
+ }
+
+ @Test
+ @DisplayName("SET with negative EX value")
+ void testSetNegativeEx() {
+ assertError("SET", TEST_KEY, "value", "EX", "-1");
+ }
+
+ @Test
+ @DisplayName("SET with EX and NX options combined")
+ void testSetExAndNx() {
+ assertOk("SET", "expiry_key", "value", "EX", "60", "NX");
+ assertEquals("value", db.get("expiry_key"));
+ assertTrue(db.getExpiryTime("expiry_key") > System.currentTimeMillis());
+ }
+
+ @Test
+ @DisplayName("SET wrong number of arguments")
+ void testSetWrongArgs() {
+ assertErrorContains("wrong number of arguments", "SET", TEST_KEY);
+ }
+
+ @Test
+ @DisplayName("SET with conflicting NX and XX")
+ void testSetNxXxConflict() {
+ assertError("SET", TEST_KEY, "value", "NX", "XX");
+ }
+ }
+
+ // ==================== GET Command Tests ====================
+
+ @Nested
+ @DisplayName("GET Command")
+ class GetCommandTests {
+
+ @Test
+ @DisplayName("GET existing key")
+ void testGetExisting() {
+ db.put(TEST_KEY, "hello");
+ assertBulkString("hello", "GET", TEST_KEY);
+ }
+
+ @Test
+ @DisplayName("GET non-existent key")
+ void testGetNonExistent() {
+ assertNil("GET", "nonexistent_key");
+ }
+
+ @Test
+ @DisplayName("GET empty string value")
+ void testGetEmptyString() {
+ db.put(TEST_KEY, "");
+ String result = sendCommand("GET", TEST_KEY);
+ assertEquals("$0\r\n\r\n", result);
+ }
+
+ @Test
+ @DisplayName("GET after SET")
+ void testGetAfterSet() {
+ assertOk("SET", TEST_KEY, "world");
+ assertBulkString("world", "GET", TEST_KEY);
+ }
+
+ @Test
+ @DisplayName("GET wrong number of arguments - no args")
+ void testGetNoArgs() {
+ assertErrorContains("wrong number of arguments", "GET");
+ }
+
+ @Test
+ @DisplayName("GET wrong number of arguments - too many")
+ void testGetTooManyArgs() {
+ assertErrorContains("wrong number of arguments", "GET", "key1", "key2");
+ }
+
+ @Test
+ @DisplayName("GET on wrong type (list)")
+ void testGetOnWrongType() {
+ db.put(TEST_KEY, com.redis.storage.RedisValue.list(new java.util.ArrayList<>()));
+ assertNil("GET", TEST_KEY); // Returns nil for wrong type
+ }
+ }
+
+ // ==================== INCR Command Tests ====================
+
+ @Nested
+ @DisplayName("INCR Command")
+ class IncrCommandTests {
+
+ @Test
+ @DisplayName("INCR existing numeric key")
+ void testIncrExisting() {
+ db.put(COUNTER_KEY, "10");
+ assertInteger(11, "INCR", COUNTER_KEY);
+ assertEquals("11", db.get(COUNTER_KEY));
+ }
+
+ @Test
+ @DisplayName("INCR non-existent key")
+ void testIncrNonExistent() {
+ assertInteger(1, "INCR", "incr_key");
+ assertEquals("1", db.get("incr_key"));
+ }
+
+ @Test
+ @DisplayName("INCR zero")
+ void testIncrZero() {
+ db.put(COUNTER_KEY, "0");
+ assertInteger(1, "INCR", COUNTER_KEY);
+ }
+
+ @Test
+ @DisplayName("INCR negative number")
+ void testIncrNegative() {
+ db.put(COUNTER_KEY, "-5");
+ assertInteger(-4, "INCR", COUNTER_KEY);
+ }
+
+ @Test
+ @DisplayName("INCR multiple times")
+ void testIncrMultiple() {
+ db.put(COUNTER_KEY, "0");
+ assertInteger(1, "INCR", COUNTER_KEY);
+ assertInteger(2, "INCR", COUNTER_KEY);
+ assertInteger(3, "INCR", COUNTER_KEY);
+ assertEquals("3", db.get(COUNTER_KEY));
+ }
+
+ @Test
+ @DisplayName("INCR on non-integer string")
+ void testIncrNonInteger() {
+ db.put(COUNTER_KEY, "hello");
+ assertErrorContains("not an integer", "INCR", COUNTER_KEY);
+ assertEquals("hello", db.get(COUNTER_KEY)); // Value unchanged
+ }
+
+ @Test
+ @DisplayName("INCR on float value")
+ void testIncrFloat() {
+ db.put(COUNTER_KEY, "3.14");
+ assertErrorContains("not an integer", "INCR", COUNTER_KEY);
+ }
+
+ @Test
+ @DisplayName("INCR on wrong type (list)")
+ void testIncrWrongType() {
+ db.put(COUNTER_KEY, com.redis.storage.RedisValue.list(new java.util.ArrayList<>()));
+ assertErrorContains("WRONGTYPE", "INCR", COUNTER_KEY);
+ }
+
+ @Test
+ @DisplayName("INCR overflow protection")
+ void testIncrOverflow() {
+ db.put("overflow_key", String.valueOf(Long.MAX_VALUE));
+ assertErrorContains("not an integer or out of range", "INCR", "overflow_key");
+ }
+
+ @Test
+ @DisplayName("INCR wrong number of arguments")
+ void testIncrWrongArgs() {
+ assertErrorContains("wrong number of arguments", "INCR");
+ assertErrorContains("wrong number of arguments", "INCR", "key1", "key2");
+ }
+
+ @Test
+ @DisplayName("INCR large number")
+ void testIncrLargeNumber() {
+ db.put(COUNTER_KEY, "999999999");
+ assertInteger(1000000000L, "INCR", COUNTER_KEY);
+ }
+ }
+
+ // ==================== Combined Operations ====================
+
+ @Nested
+ @DisplayName("Combined String Operations")
+ class CombinedOperationsTests {
+
+ @Test
+ @DisplayName("SET then GET then INCR workflow")
+ void testSetGetIncrWorkflow() {
+ assertOk("SET", COUNTER_KEY, "100");
+ assertBulkString("100", "GET", COUNTER_KEY);
+ assertInteger(101, "INCR", COUNTER_KEY);
+ assertBulkString("101", "GET", COUNTER_KEY);
+ }
+
+ @Test
+ @DisplayName("Multiple keys operations")
+ void testMultipleKeys() {
+ assertOk("SET", TEST_KEY, "value1");
+ assertOk("SET", TEST_KEY2, "value2");
+ assertBulkString("value1", "GET", TEST_KEY);
+ assertBulkString("value2", "GET", TEST_KEY2);
+ }
+
+ @Test
+ @DisplayName("Overwrite and verify")
+ void testOverwriteAndVerify() {
+ for (int i = 0; i < 10; i++) {
+ assertOk("SET", TEST_KEY, "value" + i);
+ assertBulkString("value" + i, "GET", TEST_KEY);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/redis/integration/TransactionCommandsIT.java b/src/test/java/com/redis/integration/TransactionCommandsIT.java
new file mode 100644
index 0000000..def82ea
--- /dev/null
+++ b/src/test/java/com/redis/integration/TransactionCommandsIT.java
@@ -0,0 +1,407 @@
+package com.redis.integration;
+
+import com.redis.storage.RedisValue;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.*;
+
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for Transaction commands: MULTI, EXEC, DISCARD.
+ *
+ * Tests the complete request-response cycle through the RESP protocol
+ * with real EmbeddedChannel and RedisDatabase instances.
+ *
+ * Test Coverage:
+ * - Basic MULTI/EXEC flow
+ * - MULTI/DISCARD flow
+ * - Error handling
+ * - Transaction isolation
+ * - Large transactions
+ */
+@DisplayName("Transaction Commands Integration Tests")
+public class TransactionCommandsIT extends BaseIntegrationTest {
+
+ private static final String TX_KEY = "tx_it_key";
+ private static final String TX_KEY2 = "tx_it_key2";
+ private static final String TX_COUNTER = "tx_it_counter";
+
+ @AfterEach
+ void cleanup() {
+ cleanupKeys(TX_KEY, TX_KEY2, TX_COUNTER);
+ for (int i = 0; i < 100; i++) {
+ db.remove("tx_large_" + i);
+ }
+ }
+
+ // ==================== MULTI Command Tests ====================
+
+ @Nested
+ @DisplayName("MULTI Command")
+ class MultiCommandTests {
+
+ @Test
+ @DisplayName("MULTI returns OK")
+ void testMultiReturnsOk() {
+ assertOk("MULTI");
+ }
+
+ @Test
+ @DisplayName("Nested MULTI returns error")
+ void testNestedMulti() {
+ assertOk("MULTI");
+ assertErrorContains("nested", "MULTI");
+ }
+
+ @Test
+ @DisplayName("Commands after MULTI return QUEUED")
+ void testCommandsQueued() {
+ assertOk("MULTI");
+ assertSimpleString("QUEUED", "SET", TX_KEY, "value");
+ assertSimpleString("QUEUED", "GET", TX_KEY);
+ assertSimpleString("QUEUED", "INCR", TX_COUNTER);
+ }
+ }
+
+ // ==================== EXEC Command Tests ====================
+
+ @Nested
+ @DisplayName("EXEC Command")
+ class ExecCommandTests {
+
+ @Test
+ @DisplayName("EXEC without MULTI returns error")
+ void testExecWithoutMulti() {
+ assertErrorContains("without MULTI", "EXEC");
+ }
+
+ @Test
+ @DisplayName("EXEC executes queued commands")
+ void testExecExecutesCommands() {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "hello");
+ sendCommand("GET", TX_KEY);
+
+ String result = sendCommand("EXEC");
+ assertNotNull(result);
+ assertTrue(result.startsWith("*2\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ assertTrue(result.contains("$5\r\nhello\r\n"));
+
+ assertEquals("hello", db.get(TX_KEY));
+ }
+
+ @Test
+ @DisplayName("EXEC on empty transaction returns empty array")
+ void testExecEmptyTransaction() {
+ sendCommand("MULTI");
+ assertEmptyArray("EXEC");
+ }
+
+ @Test
+ @DisplayName("EXEC with INCR operations")
+ void testExecWithIncr() {
+ db.put(TX_COUNTER, "0");
+
+ sendCommand("MULTI");
+ sendCommand("INCR", TX_COUNTER);
+ sendCommand("INCR", TX_COUNTER);
+ sendCommand("INCR", TX_COUNTER);
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.contains(":1\r\n"));
+ assertTrue(result.contains(":2\r\n"));
+ assertTrue(result.contains(":3\r\n"));
+
+ assertEquals("3", db.get(TX_COUNTER));
+ }
+
+ @Test
+ @DisplayName("EXEC preserves command order")
+ void testExecPreservesOrder() {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "v1");
+ sendCommand("SET", TX_KEY, "v2");
+ sendCommand("SET", TX_KEY, "v3");
+ sendCommand("GET", TX_KEY);
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*4\r\n"));
+ assertTrue(result.contains("$2\r\nv3\r\n"));
+ assertEquals("v3", db.get(TX_KEY));
+ }
+ }
+
+ // ==================== DISCARD Command Tests ====================
+
+ @Nested
+ @DisplayName("DISCARD Command")
+ class DiscardCommandTests {
+
+ @Test
+ @DisplayName("DISCARD without MULTI returns error")
+ void testDiscardWithoutMulti() {
+ assertErrorContains("without MULTI", "DISCARD");
+ }
+
+ @Test
+ @DisplayName("DISCARD cancels transaction")
+ void testDiscardCancelsTransaction() {
+ db.put(TX_KEY, "original");
+
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "modified");
+ assertOk("DISCARD");
+
+ assertEquals("original", db.get(TX_KEY));
+ }
+
+ @Test
+ @DisplayName("DISCARD clears queued commands")
+ void testDiscardClearsQueue() {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "value");
+ sendCommand("SET", TX_KEY2, "value");
+ assertOk("DISCARD");
+
+ // Should be able to start new transaction
+ assertOk("MULTI");
+ assertEmptyArray("EXEC");
+ }
+
+ @Test
+ @DisplayName("Can start new transaction after DISCARD")
+ void testNewTransactionAfterDiscard() {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "discarded");
+ sendCommand("DISCARD");
+
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "actual");
+ sendCommand("EXEC");
+
+ assertEquals("actual", db.get(TX_KEY));
+ }
+ }
+
+ // ==================== Error Handling Tests ====================
+
+ @Nested
+ @DisplayName("Transaction Error Handling")
+ class ErrorHandlingTests {
+
+ @Test
+ @DisplayName("Unknown command in transaction marks error")
+ void testUnknownCommandMarksError() {
+ sendCommand("MULTI");
+ String queueResult = sendCommand("INVALIDCMD", "arg");
+ assertTrue(queueResult.contains("ERR"));
+
+ String execResult = sendCommand("EXEC");
+ assertTrue(execResult.contains("EXECABORT") || execResult.contains("discarded"));
+ }
+
+ @Test
+ @DisplayName("Command errors during EXEC are returned in array")
+ void testCommandErrorsInExec() {
+ db.put(TX_KEY, RedisValue.list(new ArrayList<>()));
+
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY2, "value"); // Should succeed
+ sendCommand("INCR", TX_KEY); // Should fail - wrong type
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*2\r\n"));
+ assertTrue(result.contains("+OK\r\n"));
+ assertTrue(result.contains("WRONGTYPE") || result.contains("-ERR"));
+
+ // First command should have succeeded
+ assertEquals("value", db.get(TX_KEY2));
+ }
+ }
+
+ // ==================== Transaction Isolation Tests ====================
+
+ @Nested
+ @DisplayName("Transaction Isolation")
+ class IsolationTests {
+
+ @Test
+ @DisplayName("Transaction state isolated per connection")
+ void testIsolationPerConnection() {
+ EmbeddedChannel channel2 = createNewChannel();
+
+ try {
+ // Start transaction on channel 1
+ assertOk("MULTI");
+
+ // Channel 2 should be able to start its own transaction
+ String result = sendCommandOn(channel2, "MULTI");
+ assertEquals("+OK\r\n", result);
+
+ // Both can queue commands
+ assertSimpleString("QUEUED", "SET", TX_KEY, "channel1");
+ assertEquals("+QUEUED\r\n", sendCommandOn(channel2, "SET", TX_KEY2, "channel2"));
+
+ } finally {
+ channel2.close();
+ }
+ }
+
+ @Test
+ @DisplayName("EXEC on one connection doesn't affect another")
+ void testExecIsolation() {
+ EmbeddedChannel channel2 = createNewChannel();
+
+ try {
+ // Channel 1 starts and completes transaction
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "from_channel1");
+ sendCommand("EXEC");
+
+ // Channel 2 starts transaction
+ sendCommandOn(channel2, "MULTI");
+ sendCommandOn(channel2, "SET", TX_KEY2, "from_channel2");
+
+ // Channel 1's data should be visible
+ assertEquals("from_channel1", db.get(TX_KEY));
+
+ // Channel 2 hasn't committed yet
+ assertNull(db.get(TX_KEY2));
+
+ sendCommandOn(channel2, "EXEC");
+ assertEquals("from_channel2", db.get(TX_KEY2));
+
+ } finally {
+ channel2.close();
+ }
+ }
+ }
+
+ // ==================== Large Transaction Tests ====================
+
+ @Nested
+ @DisplayName("Large Transactions")
+ class LargeTransactionTests {
+
+ @Test
+ @DisplayName("Transaction with 100 commands")
+ void testLargeTransaction() {
+ sendCommand("MULTI");
+
+ for (int i = 0; i < 100; i++) {
+ String queueResult = sendCommand("SET", "tx_large_" + i, "value" + i);
+ assertEquals("+QUEUED\r\n", queueResult);
+ }
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*100\r\n"));
+
+ // Verify all were set
+ for (int i = 0; i < 100; i++) {
+ assertEquals("value" + i, db.get("tx_large_" + i));
+ }
+ }
+
+ @Test
+ @DisplayName("Large transaction with DISCARD")
+ void testLargeTransactionDiscard() {
+ sendCommand("MULTI");
+
+ for (int i = 0; i < 50; i++) {
+ sendCommand("SET", "tx_large_" + i, "value");
+ }
+
+ assertOk("DISCARD");
+
+ // None should be set
+ for (int i = 0; i < 50; i++) {
+ assertNull(db.get("tx_large_" + i));
+ }
+ }
+ }
+
+ // ==================== Combined Operations Tests ====================
+
+ @Nested
+ @DisplayName("Combined Transaction Operations")
+ class CombinedOperationsTests {
+
+ @Test
+ @DisplayName("Multiple transactions in sequence")
+ void testMultipleTransactionsSequence() {
+ for (int i = 0; i < 5; i++) {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "iteration" + i);
+ sendCommand("EXEC");
+ assertEquals("iteration" + i, db.get(TX_KEY));
+ }
+ }
+
+ @Test
+ @DisplayName("Transaction with multiple data types")
+ void testTransactionMultipleTypes() {
+ sendCommand("MULTI");
+ sendCommand("SET", TX_KEY, "string_value");
+ sendCommand("LPUSH", TX_KEY2, "list_item");
+ sendCommand("TYPE", TX_KEY);
+ sendCommand("TYPE", TX_KEY2);
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*4\r\n"));
+ assertTrue(result.contains("+string\r\n"));
+ assertTrue(result.contains("+list\r\n"));
+ }
+
+ @Test
+ @DisplayName("Read-only transaction")
+ void testReadOnlyTransaction() {
+ db.put(TX_KEY, "value1");
+ db.put(TX_KEY2, "value2");
+
+ sendCommand("MULTI");
+ sendCommand("GET", TX_KEY);
+ sendCommand("GET", TX_KEY2);
+ sendCommand("TYPE", TX_KEY);
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*3\r\n"));
+ assertTrue(result.contains("$6\r\nvalue1\r\n"));
+ assertTrue(result.contains("$6\r\nvalue2\r\n"));
+ assertTrue(result.contains("+string\r\n"));
+ }
+
+ @Test
+ @DisplayName("Transaction with DEL command")
+ void testTransactionWithDel() {
+ db.put(TX_KEY, "to_delete");
+
+ sendCommand("MULTI");
+ sendCommand("DEL", TX_KEY);
+ sendCommand("GET", TX_KEY);
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.contains(":1\r\n")); // DEL returns 1
+ assertTrue(result.contains("$-1\r\n")); // GET returns nil
+
+ assertNull(db.get(TX_KEY));
+ }
+
+ @Test
+ @DisplayName("Transaction with PING and ECHO")
+ void testTransactionWithPingEcho() {
+ sendCommand("MULTI");
+ sendCommand("PING");
+ sendCommand("ECHO", "hello");
+ sendCommand("PING", "world");
+
+ String result = sendCommand("EXEC");
+ assertTrue(result.startsWith("*3\r\n"));
+ assertTrue(result.contains("+PONG\r\n"));
+ assertTrue(result.contains("$5\r\nhello\r\n"));
+ assertTrue(result.contains("$5\r\nworld\r\n"));
+ }
+ }
+}
diff --git a/src/test/java/com/redis/transaction/TransactionContextTest.java b/src/test/java/com/redis/transaction/TransactionContextTest.java
new file mode 100644
index 0000000..ab4d951
--- /dev/null
+++ b/src/test/java/com/redis/transaction/TransactionContextTest.java
@@ -0,0 +1,372 @@
+package com.redis.transaction;
+
+import com.redis.commands.ICommand;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for TransactionContext.
+ * Tests the transaction state management logic in isolation.
+ *
+ * Test Coverage:
+ * - Transaction lifecycle (start, queue, discard, end)
+ * - Command queueing and retrieval
+ * - Error tracking
+ * - State transitions
+ * - Edge cases
+ */
+@DisplayName("TransactionContext Unit Tests")
+public class TransactionContextTest {
+
+ private EmbeddedChannel channel;
+ private TransactionContext context;
+ private ICommand mockCommand;
+
+ @BeforeEach
+ void setUp() {
+ channel = new EmbeddedChannel();
+ context = new TransactionContext();
+ mockCommand = mock(ICommand.class);
+ }
+
+ @Test
+ @DisplayName("New context is not in transaction")
+ void testNewContextNotInTransaction() {
+ assertFalse(context.isInTransaction());
+ assertFalse(context.hasErrors());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("startTransaction initializes transaction state")
+ void testStartTransaction() {
+ boolean result = context.startTransaction();
+
+ assertTrue(result);
+ assertTrue(context.isInTransaction());
+ assertFalse(context.hasErrors());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("startTransaction fails when already in transaction")
+ void testStartTransactionWhenAlreadyInTransaction() {
+ context.startTransaction();
+
+ boolean result = context.startTransaction();
+
+ assertFalse(result);
+ assertTrue(context.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("queueCommand adds command to queue")
+ void testQueueCommand() {
+ context.startTransaction();
+ List args = Arrays.asList("key", "value");
+
+ context.queueCommand(mockCommand, args);
+
+ assertEquals(1, context.queueSize());
+ List queuedCommands = context.getQueuedCommands();
+ assertEquals(1, queuedCommands.size());
+ assertEquals(mockCommand, queuedCommands.get(0).command());
+ assertEquals(args, queuedCommands.get(0).args());
+ }
+
+ @Test
+ @DisplayName("queueCommand creates defensive copy of args")
+ void testQueueCommandCreatesDefensiveCopy() {
+ context.startTransaction();
+ List args = Arrays.asList("key", "value");
+
+ context.queueCommand(mockCommand, args);
+
+ List queuedCommands = context.getQueuedCommands();
+ List queuedArgs = queuedCommands.get(0).args();
+
+ // Queued args should be a different list (defensive copy)
+ assertNotSame(args, queuedArgs);
+ assertEquals(args, queuedArgs);
+ }
+
+ @Test
+ @DisplayName("queueCommand can queue multiple commands")
+ void testQueueMultipleCommands() {
+ context.startTransaction();
+
+ context.queueCommand(mockCommand, List.of("key1", "value1"));
+ context.queueCommand(mockCommand, List.of("key2", "value2"));
+ context.queueCommand(mockCommand, List.of("key3", "value3"));
+
+ assertEquals(3, context.queueSize());
+ List queuedCommands = context.getQueuedCommands();
+ assertEquals(3, queuedCommands.size());
+ }
+
+ @Test
+ @DisplayName("markError sets error flag")
+ void testMarkError() {
+ context.startTransaction();
+
+ context.markError();
+
+ assertTrue(context.hasErrors());
+ assertTrue(context.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("discard clears queue and ends transaction")
+ void testDiscard() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key", "value"));
+ context.markError();
+
+ boolean result = context.discard();
+
+ assertTrue(result);
+ assertFalse(context.isInTransaction());
+ assertFalse(context.hasErrors());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("discard fails when not in transaction")
+ void testDiscardWhenNotInTransaction() {
+ boolean result = context.discard();
+
+ assertFalse(result);
+ assertFalse(context.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("endTransaction resets state")
+ void testEndTransaction() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key", "value"));
+ context.markError();
+
+ context.endTransaction();
+
+ assertFalse(context.isInTransaction());
+ assertFalse(context.hasErrors());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("getOrCreate returns new context for channel")
+ void testGetOrCreate() {
+ TransactionContext ctx = TransactionContext.getOrCreate(channel);
+
+ assertNotNull(ctx);
+ assertFalse(ctx.isInTransaction());
+ }
+
+ @Test
+ @DisplayName("getOrCreate returns same context on subsequent calls")
+ void testGetOrCreateReturnsSameContext() {
+ TransactionContext ctx1 = TransactionContext.getOrCreate(channel);
+ TransactionContext ctx2 = TransactionContext.getOrCreate(channel);
+
+ assertSame(ctx1, ctx2);
+ }
+
+ @Test
+ @DisplayName("get returns null when no context exists")
+ void testGetReturnsNullWhenNoContext() {
+ TransactionContext ctx = TransactionContext.get(channel);
+
+ assertNull(ctx);
+ }
+
+ @Test
+ @DisplayName("get returns context after getOrCreate")
+ void testGetReturnsContextAfterGetOrCreate() {
+ TransactionContext ctx1 = TransactionContext.getOrCreate(channel);
+ TransactionContext ctx2 = TransactionContext.get(channel);
+
+ assertSame(ctx1, ctx2);
+ }
+
+ @Test
+ @DisplayName("startTransaction clears previous queue")
+ void testStartTransactionClearsPreviousQueue() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key1", "value1"));
+ context.endTransaction();
+
+ context.startTransaction();
+
+ assertEquals(0, context.queueSize());
+ assertFalse(context.hasErrors());
+ }
+
+ @Test
+ @DisplayName("startTransaction resets error flag")
+ void testStartTransactionResetsErrorFlag() {
+ context.startTransaction();
+ context.markError();
+ context.endTransaction();
+
+ context.startTransaction();
+
+ assertFalse(context.hasErrors());
+ }
+
+ @Test
+ @DisplayName("Queue can handle many commands")
+ void testQueueCanHandleManyCommands() {
+ context.startTransaction();
+
+ // Queue 1000 commands
+ for (int i = 0; i < 1000; i++) {
+ context.queueCommand(mockCommand, List.of("key" + i, "value" + i));
+ }
+
+ assertEquals(1000, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("getQueuedCommands returns direct list reference")
+ void testGetQueuedCommandsReturnsDirectReference() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key", "value"));
+
+ List queue1 = context.getQueuedCommands();
+ List queue2 = context.getQueuedCommands();
+
+ // Should be same reference for performance
+ assertSame(queue1, queue2);
+ }
+
+ @Test
+ @DisplayName("Transaction state persists across multiple operations")
+ void testTransactionStatePersistsAcrossOperations() {
+ context.startTransaction();
+ assertTrue(context.isInTransaction());
+
+ context.queueCommand(mockCommand, List.of("key1", "value1"));
+ assertTrue(context.isInTransaction());
+ assertEquals(1, context.queueSize());
+
+ context.queueCommand(mockCommand, List.of("key2", "value2"));
+ assertTrue(context.isInTransaction());
+ assertEquals(2, context.queueSize());
+
+ context.markError();
+ assertTrue(context.isInTransaction());
+ assertTrue(context.hasErrors());
+ }
+
+ @Test
+ @DisplayName("Discard allows starting new transaction")
+ void testDiscardAllowsStartingNewTransaction() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key", "value"));
+ context.discard();
+
+ boolean result = context.startTransaction();
+
+ assertTrue(result);
+ assertTrue(context.isInTransaction());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("endTransaction allows starting new transaction")
+ void testEndTransactionAllowsStartingNewTransaction() {
+ context.startTransaction();
+ context.queueCommand(mockCommand, List.of("key", "value"));
+ context.endTransaction();
+
+ boolean result = context.startTransaction();
+
+ assertTrue(result);
+ assertTrue(context.isInTransaction());
+ assertEquals(0, context.queueSize());
+ }
+
+ @Test
+ @DisplayName("Multiple errors can be marked")
+ void testMultipleErrorsCanBeMarked() {
+ context.startTransaction();
+
+ context.markError();
+ assertTrue(context.hasErrors());
+
+ context.markError();
+ assertTrue(context.hasErrors());
+
+ // Error flag remains true
+ assertTrue(context.hasErrors());
+ }
+
+ @Test
+ @DisplayName("QueuedCommand record stores command and args")
+ void testQueuedCommandRecord() {
+ List args = List.of("key", "value");
+ TransactionContext.QueuedCommand qc = new TransactionContext.QueuedCommand(mockCommand, args);
+
+ assertEquals(mockCommand, qc.command());
+ assertEquals(args, qc.args());
+ }
+
+ @Test
+ @DisplayName("Empty args list can be queued")
+ void testEmptyArgsListCanBeQueued() {
+ context.startTransaction();
+ List emptyArgs = List.of();
+
+ context.queueCommand(mockCommand, emptyArgs);
+
+ assertEquals(1, context.queueSize());
+ List queuedCommands = context.getQueuedCommands();
+ assertEquals(0, queuedCommands.get(0).args().size());
+ }
+
+ @Test
+ @DisplayName("Context isolation between channels")
+ void testContextIsolationBetweenChannels() {
+ EmbeddedChannel channel1 = new EmbeddedChannel();
+ EmbeddedChannel channel2 = new EmbeddedChannel();
+
+ TransactionContext ctx1 = TransactionContext.getOrCreate(channel1);
+ TransactionContext ctx2 = TransactionContext.getOrCreate(channel2);
+
+ assertNotSame(ctx1, ctx2);
+
+ ctx1.startTransaction();
+ assertTrue(ctx1.isInTransaction());
+ assertFalse(ctx2.isInTransaction());
+
+ channel1.close();
+ channel2.close();
+ }
+
+ @Test
+ @DisplayName("Queue maintains insertion order")
+ void testQueueMaintainsInsertionOrder() {
+ context.startTransaction();
+
+ ICommand cmd1 = mock(ICommand.class);
+ ICommand cmd2 = mock(ICommand.class);
+ ICommand cmd3 = mock(ICommand.class);
+
+ context.queueCommand(cmd1, List.of("arg1"));
+ context.queueCommand(cmd2, List.of("arg2"));
+ context.queueCommand(cmd3, List.of("arg3"));
+
+ List queue = context.getQueuedCommands();
+ assertEquals(cmd1, queue.get(0).command());
+ assertEquals(cmd2, queue.get(1).command());
+ assertEquals(cmd3, queue.get(2).command());
+ }
+}
\ No newline at end of file