diff --git a/build.gradle b/build.gradle index eb4ae53..2050cc9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' id 'application' + id 'checkstyle' } group = 'org.example' @@ -13,6 +14,7 @@ repositories { dependencies { testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-junit-jupiter:5.12.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -26,4 +28,9 @@ tasks.withType(JavaCompile).configureEach { test { useJUnitPlatform() + systemProperty 'net.bytebuddy.experimental', 'true' +} + +checkstyle { + configFile = file('config/checkstyle/checkstyle.xml') } \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..0de7181 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/org/cache/Main.java b/src/main/java/org/cache/Main.java index e066ed6..706018c 100644 --- a/src/main/java/org/cache/Main.java +++ b/src/main/java/org/cache/Main.java @@ -3,6 +3,7 @@ import org.cache.core.LocalCache; import org.cache.core.ValueType; import org.cache.eviction.LruEvictionPolicy; +import org.cache.network.connection.ClientConnectionHandler; import org.cache.network.TcpCacheServer; import org.cache.protocol.CommandParser; import org.cache.protocol.CommandProcessor; @@ -12,8 +13,12 @@ import org.cache.protocol.codec.ValueCodecRegistry; import java.io.IOException; +import java.net.ServerSocket; +import java.util.concurrent.Executors; public class Main { + private static final int SERVER_THREAD_COUNT = 16; + public static void main(String[] args) throws IOException { int port = args.length > 0 ? Integer.parseInt(args[0]) : 2020; var cache = new LocalCache(1_000, new LruEvictionPolicy<>()); @@ -24,7 +29,14 @@ public static void main(String[] args) throws IOException { var commandProcessor = new CommandProcessor<>(cache, commandParser, valueCodecs); - try (cache; var server = new TcpCacheServer(port, commandProcessor)) { + try (cache; + var serverSocket = new ServerSocket(port); + var executor = Executors.newFixedThreadPool(SERVER_THREAD_COUNT); + var server = new TcpCacheServer( + serverSocket, + executor, + socket -> new ClientConnectionHandler(socket, commandProcessor) + )) { server.start(); } } diff --git a/src/main/java/org/cache/client/CacheClient.java b/src/main/java/org/cache/client/CacheClient.java index 8912bae..3d59459 100644 --- a/src/main/java/org/cache/client/CacheClient.java +++ b/src/main/java/org/cache/client/CacheClient.java @@ -2,6 +2,7 @@ import org.cache.core.metrics.Snapshot; +import java.util.List; import java.util.Optional; public interface CacheClient { @@ -19,4 +20,10 @@ public interface CacheClient { int size(); void clear(); + + void push(K key, V value); + + List lrange(K key, int to); + + List lrange(K key, int from, int to); } diff --git a/src/main/java/org/cache/client/TcpCacheClient.java b/src/main/java/org/cache/client/TcpCacheClient.java index 775f064..ac3d584 100644 --- a/src/main/java/org/cache/client/TcpCacheClient.java +++ b/src/main/java/org/cache/client/TcpCacheClient.java @@ -24,6 +24,7 @@ public class TcpCacheClient implements CacheClient, AutoCloseable { private static final int FIRST_VALUE_INDEX = 1; private static final int METRIC_NAME_VALUE_START_INDEX = 1; private static final int METRIC_NAME_VALUE_PAIR_SIZE = 2; + private static final int DEFAULT_LRANGE_FROM_INDEX = 0; private final RespConnection connection; private final KeyCodec keyCodec; @@ -45,6 +46,12 @@ public TcpCacheClient( } } + TcpCacheClient(RespConnection connection, KeyCodec keyCodec, Serializer valueSerializer) { + this.connection = connection; + this.keyCodec = keyCodec; + this.valueSerializer = valueSerializer; + } + @Override public void put(K key, V value, long ttl) { expectOk(List.of("PUT", keyCodec.encode(key), valueSerializer.encode(value), Long.toString(ttl))); @@ -127,6 +134,34 @@ public void clear() { expectOk(List.of("CLEAR")); } + @Override + public void push(K key, V value) { + expectOk(List.of("PUSH", keyCodec.encode(key), valueSerializer.encode(value))); + } + + @Override + public List lrange(K key, int to) { + return lrange(key, DEFAULT_LRANGE_FROM_INDEX, to); + } + + @Override + public List lrange(K key, int from, int to) { + List response = send(List.of( + "LRANGE", + keyCodec.encode(key), + Integer.toString(from), + Integer.toString(to) + )); + + if (isResponse(response, ResponseConstants.NOT_FOUND.name(), SINGLE_PART_RESPONSE_SIZE)) { + return List.of(); + } + + return response.stream() + .map(valueSerializer::decode) + .toList(); + } + @Override public void close() { try { diff --git a/src/main/java/org/cache/eviction/LruEvictionPolicy.java b/src/main/java/org/cache/eviction/LruEvictionPolicy.java index d4b349d..cfccd9a 100644 --- a/src/main/java/org/cache/eviction/LruEvictionPolicy.java +++ b/src/main/java/org/cache/eviction/LruEvictionPolicy.java @@ -46,7 +46,9 @@ public synchronized void onKeyAccessed(K key) { @Override public synchronized void onKeyRemoved(K key) { var removedNode = keyMap.get(key); - if (removedNode == null) return; + if (removedNode == null) { + return; + } var prevNode = removedNode.prev; var nextNode = removedNode.next; @@ -67,4 +69,4 @@ public synchronized Optional selectVictim() { return Optional.ofNullable(selectedNode.value); } -} \ No newline at end of file +} diff --git a/src/main/java/org/cache/eviction/MruEvictionPolicy.java b/src/main/java/org/cache/eviction/MruEvictionPolicy.java index 25af74b..7c475cb 100644 --- a/src/main/java/org/cache/eviction/MruEvictionPolicy.java +++ b/src/main/java/org/cache/eviction/MruEvictionPolicy.java @@ -46,7 +46,9 @@ public synchronized void onKeyAccessed(K key) { @Override public synchronized void onKeyRemoved(K key) { var removedNode = keyMap.get(key); - if (removedNode == null) return; + if (removedNode == null) { + return; + } var prevNode = removedNode.prev; var nextNode = removedNode.next; @@ -67,4 +69,4 @@ public synchronized Optional selectVictim() { return Optional.ofNullable(selectedNode.value); } -} \ No newline at end of file +} diff --git a/src/main/java/org/cache/network/ClientConnectionHandlerFactory.java b/src/main/java/org/cache/network/ClientConnectionHandlerFactory.java new file mode 100644 index 0000000..7d38654 --- /dev/null +++ b/src/main/java/org/cache/network/ClientConnectionHandlerFactory.java @@ -0,0 +1,9 @@ +package org.cache.network; + +import java.net.Socket; + +@FunctionalInterface +public interface ClientConnectionHandlerFactory { + + Runnable create(Socket socket); +} diff --git a/src/main/java/org/cache/network/TcpCacheServer.java b/src/main/java/org/cache/network/TcpCacheServer.java index 8609ebe..5a00aa8 100644 --- a/src/main/java/org/cache/network/TcpCacheServer.java +++ b/src/main/java/org/cache/network/TcpCacheServer.java @@ -1,36 +1,32 @@ package org.cache.network; -import org.cache.network.connection.ClientConnectionHandler; -import org.cache.protocol.CommandProcessor; - import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; public class TcpCacheServer implements AutoCloseable { - private final int port; - private final CommandProcessor commandProcessor; private final ExecutorService executor; + private final ClientConnectionHandlerFactory handlerFactory; private ServerSocket serverSocket; - private static final int THREAD_COUNT = 16; - - public TcpCacheServer(int port, CommandProcessor commandProcessor) { - this.port = port; - this.commandProcessor = commandProcessor; - this.executor = Executors.newFixedThreadPool(THREAD_COUNT); + public TcpCacheServer( + ServerSocket serverSocket, + ExecutorService executor, + ClientConnectionHandlerFactory handlerFactory + ) { + this.serverSocket = serverSocket; + this.executor = executor; + this.handlerFactory = handlerFactory; } public void start() throws IOException { - serverSocket = new ServerSocket(port); - System.out.println("TCP cache server listening on port " + port); + System.out.println("TCP cache server listening on port " + serverSocket.getLocalPort()); while (!Thread.currentThread().isInterrupted() && !serverSocket.isClosed()) { Socket socket = serverSocket.accept(); - executor.submit(new ClientConnectionHandler(socket, commandProcessor)); + executor.submit(handlerFactory.create(socket)); } } diff --git a/src/main/java/org/cache/network/connection/RespConnection.java b/src/main/java/org/cache/network/connection/RespConnection.java index 410edd0..57e6004 100644 --- a/src/main/java/org/cache/network/connection/RespConnection.java +++ b/src/main/java/org/cache/network/connection/RespConnection.java @@ -11,7 +11,14 @@ import static java.nio.charset.StandardCharsets.US_ASCII; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.cache.protocol.ProtocolConstants.*; +import static org.cache.protocol.ProtocolConstants.ARRAY_PREFIX; +import static org.cache.protocol.ProtocolConstants.BULK_STRING_PREFIX; +import static org.cache.protocol.ProtocolConstants.CARRIAGE_RETURN; +import static org.cache.protocol.ProtocolConstants.CRLF; +import static org.cache.protocol.ProtocolConstants.ERROR_PREFIX; +import static org.cache.protocol.ProtocolConstants.INTEGER_PREFIX; +import static org.cache.protocol.ProtocolConstants.SIMPLE_STRING_PREFIX; +import static org.cache.protocol.ProtocolConstants.NEW_LINE; import static org.cache.protocol.RegexConstants.COMMA_WITH_OPTIONAL_WHITESPACE; import static org.cache.protocol.RegexConstants.KEY_VALUE_SEPARATOR; import static org.cache.protocol.RegexConstants.SPACE; diff --git a/src/main/java/org/cache/protocol/CommandParser.java b/src/main/java/org/cache/protocol/CommandParser.java index af642b6..78be5ff 100644 --- a/src/main/java/org/cache/protocol/CommandParser.java +++ b/src/main/java/org/cache/protocol/CommandParser.java @@ -2,7 +2,18 @@ import org.cache.core.ValueType; import org.cache.protocol.codec.KeyCodec; -import org.cache.protocol.commands.*; +import org.cache.protocol.commands.CacheCommand; +import org.cache.protocol.commands.ClearCommand; +import org.cache.protocol.commands.CommandType; +import org.cache.protocol.commands.DeleteCommand; +import org.cache.protocol.commands.GetCommand; +import org.cache.protocol.commands.InvalidCommand; +import org.cache.protocol.commands.LrangeCommand; +import org.cache.protocol.commands.MetricsCommand; +import org.cache.protocol.commands.PushCommand; +import org.cache.protocol.commands.PutCommand; +import org.cache.protocol.commands.SizeCommand; +import org.cache.protocol.commands.UnknownCommand; import java.util.List; diff --git a/src/main/java/org/cache/protocol/codec/ListValueCodec.java b/src/main/java/org/cache/protocol/codec/ListValueCodec.java index dffb5b9..015ddbd 100644 --- a/src/main/java/org/cache/protocol/codec/ListValueCodec.java +++ b/src/main/java/org/cache/protocol/codec/ListValueCodec.java @@ -1,6 +1,10 @@ package org.cache.protocol.codec; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/org/cache/protocol/commands/LrangeCommand.java b/src/main/java/org/cache/protocol/commands/LrangeCommand.java index 8de0c0f..7ccf8a0 100644 --- a/src/main/java/org/cache/protocol/commands/LrangeCommand.java +++ b/src/main/java/org/cache/protocol/commands/LrangeCommand.java @@ -9,7 +9,9 @@ import java.util.List; import java.util.Optional; -import static org.cache.protocol.commands.ResponseConstants.*; +import static org.cache.protocol.commands.ResponseConstants.ERROR; +import static org.cache.protocol.commands.ResponseConstants.LIST; +import static org.cache.protocol.commands.ResponseConstants.NOT_FOUND; public class LrangeCommand implements CacheCommand { diff --git a/src/main/java/org/cache/protocol/commands/PutCommand.java b/src/main/java/org/cache/protocol/commands/PutCommand.java index b689f47..2d7b6de 100644 --- a/src/main/java/org/cache/protocol/commands/PutCommand.java +++ b/src/main/java/org/cache/protocol/commands/PutCommand.java @@ -5,8 +5,6 @@ import org.cache.protocol.codec.ValueCodec; import org.cache.protocol.codec.ValueCodecRegistry; -import java.util.Arrays; - import static org.cache.protocol.commands.ResponseConstants.OK; public class PutCommand implements CacheCommand { diff --git a/src/test/java/org/cache/client/TcpCacheClientTest.java b/src/test/java/org/cache/client/TcpCacheClientTest.java new file mode 100644 index 0000000..3a8bc44 --- /dev/null +++ b/src/test/java/org/cache/client/TcpCacheClientTest.java @@ -0,0 +1,173 @@ +package org.cache.client; + +import org.cache.client.serializer.StringSerializer; +import org.cache.core.metrics.Snapshot; +import org.cache.network.connection.RespConnection; +import org.cache.protocol.codec.StringKeyCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TcpCacheClientTest { + + private RespConnection connection; + private TcpCacheClient client; + + @BeforeEach + void setUp() { + connection = mock(RespConnection.class); + client = new TcpCacheClient<>(connection, new StringKeyCodec(), new StringSerializer()); + } + + @Test + void putSendsPutCommand() throws Exception { + when(connection.sendCommandForResponse(List.of("PUT", "fruit", "apple"))).thenReturn(List.of("OK")); + + client.put("fruit", "apple"); + + verify(connection).sendCommandForResponse(List.of("PUT", "fruit", "apple")); + } + + @Test + void putWithTtlSendsPutCommandWithTtl() throws Exception { + when(connection.sendCommandForResponse(List.of("PUT", "fruit", "apple", "1000"))).thenReturn(List.of("OK")); + + client.put("fruit", "apple", 1_000); + + verify(connection).sendCommandForResponse(List.of("PUT", "fruit", "apple", "1000")); + } + + @Test + void getReturnsValueWhenServerReturnsValue() throws Exception { + when(connection.sendCommandForResponse(List.of("GET", "fruit"))).thenReturn(List.of("VALUE", "apple")); + + Optional value = client.get("fruit"); + + assertEquals(Optional.of("apple"), value); + } + + @Test + void getReturnsEmptyWhenServerReturnsNotFound() throws Exception { + when(connection.sendCommandForResponse(List.of("GET", "missing"))).thenReturn(List.of("NOT_FOUND")); + + Optional value = client.get("missing"); + + assertEquals(Optional.empty(), value); + } + + @Test + void deleteSendsDeleteCommand() throws Exception { + when(connection.sendCommandForResponse(List.of("DELETE", "fruit"))).thenReturn(List.of("OK")); + + client.delete("fruit"); + + verify(connection).sendCommandForResponse(List.of("DELETE", "fruit")); + } + + @Test + void sizeReturnsParsedSize() throws Exception { + when(connection.sendCommandForResponse(List.of("SIZE"))).thenReturn(List.of("SIZE", "3")); + + int size = client.size(); + + assertEquals(3, size); + } + + @Test + void clearSendsClearCommand() throws Exception { + when(connection.sendCommandForResponse(List.of("CLEAR"))).thenReturn(List.of("OK")); + + client.clear(); + + verify(connection).sendCommandForResponse(List.of("CLEAR")); + } + + @Test + void pushSendsPushCommand() throws Exception { + when(connection.sendCommandForResponse(List.of("PUSH", "fruits", "apple"))).thenReturn(List.of("OK")); + + client.push("fruits", "apple"); + + verify(connection).sendCommandForResponse(List.of("PUSH", "fruits", "apple")); + } + + @Test + void lrangeSendsRangeCommandAndReturnsValues() throws Exception { + when(connection.sendCommandForResponse(List.of("LRANGE", "fruits", "0", "2"))) + .thenReturn(List.of("apple", "banana")); + + List values = client.lrange("fruits", 0, 2); + + assertEquals(List.of("apple", "banana"), values); + } + + @Test + void lrangeWithToUsesZeroAsFromIndex() throws Exception { + when(connection.sendCommandForResponse(List.of("LRANGE", "fruits", "0", "2"))) + .thenReturn(List.of("apple", "banana")); + + List values = client.lrange("fruits", 2); + + assertEquals(List.of("apple", "banana"), values); + } + + @Test + void lrangeReturnsEmptyListWhenServerReturnsNotFound() throws Exception { + when(connection.sendCommandForResponse(List.of("LRANGE", "missing", "0", "2"))) + .thenReturn(List.of("NOT_FOUND")); + + List values = client.lrange("missing", 0, 2); + + assertEquals(List.of(), values); + } + + @Test + void metricsReturnsParsedSnapshot() throws Exception { + when(connection.sendCommandForResponse(List.of("METRICS"))).thenReturn(List.of( + "METRICS", + "hits", "1", + "misses", "2", + "evictions", "3", + "expirations", "4", + "hitRate", "0.5" + )); + + Snapshot metrics = client.metrics(); + + assertEquals(1, metrics.getHits()); + assertEquals(2, metrics.getMisses()); + assertEquals(3, metrics.getEvictions()); + assertEquals(4, metrics.getExpirations()); + assertEquals(0.5, metrics.getHitRate()); + } + + @Test + void commandThrowsWhenServerReturnsError() throws Exception { + when(connection.sendCommandForResponse(List.of("GET", "fruit"))).thenReturn(List.of("ERROR", "broken")); + + assertThrows(CacheClientException.class, () -> client.get("fruit")); + } + + @Test + void commandThrowsWhenConnectionFails() throws Exception { + when(connection.sendCommandForResponse(List.of("GET", "fruit"))).thenThrow(new IOException("closed")); + + assertThrows(CacheClientException.class, () -> client.get("fruit")); + } + + @Test + void closeClosesConnection() throws Exception { + client.close(); + + verify(connection).close(); + } +} diff --git a/src/test/java/org/cache/core/LocalCacheTest.java b/src/test/java/org/cache/core/LocalCacheTest.java new file mode 100644 index 0000000..4c55cb7 --- /dev/null +++ b/src/test/java/org/cache/core/LocalCacheTest.java @@ -0,0 +1,138 @@ +package org.cache.core; + +import org.cache.eviction.LruEvictionPolicy; +import org.cache.core.metrics.Snapshot; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalCacheTest { + + private static final int DEFAULT_CAPACITY = 10; + + private LocalCache cache; + + @BeforeEach + void setUp() { + cache = createCache(DEFAULT_CAPACITY); + } + + @AfterEach + void tearDown() { + cache.close(); + } + + @Test + void putStoresValueAndType() { + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + cache.put("fruit", value, ValueType.STRING, 0); + + Optional entry = cache.get("fruit"); + assertTrue(entry.isPresent()); + assertArrayEquals(value, entry.get().getValue()); + assertEquals(ValueType.STRING, entry.get().getType()); + } + + @Test + void getReturnsStoredEntry() { + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + cache.put("fruit", value, ValueType.STRING, 0); + + Optional entry = cache.get("fruit"); + assertTrue(entry.isPresent()); + assertArrayEquals(value, entry.get().getValue()); + assertEquals(ValueType.STRING, entry.get().getType()); + } + + @Test + void getReturnsEmptyWhenKeyDoesNotExist() { + Optional entry = cache.get("missing"); + + assertFalse(entry.isPresent()); + } + + @Test + void deleteEntryAndReturnsEmpty() { + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + cache.put("fruit", value, ValueType.STRING, 0); + + Optional entry = cache.get("fruit"); + assertTrue(entry.isPresent()); + assertArrayEquals(value, entry.get().getValue()); + assertEquals(ValueType.STRING, entry.get().getType()); + + cache.delete("fruit"); + + entry = cache.get("fruit"); + assertFalse(entry.isPresent()); + } + + @Test + void sizeReturnsNumberOfEntry() { + int givenSize = 3; + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + for (int i = 0; i < givenSize; i++) { + cache.put("fruit" + i, value, ValueType.STRING, 0); + } + + int cacheSize = cache.size(); + + assertEquals(givenSize, cacheSize); + } + + @Test + void clearReturnsEmptyCache() { + int givenSize = 3; + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + for (int i = 0; i < givenSize; i++) { + cache.put("fruit" + i, value, ValueType.STRING, 0); + } + + int cacheSize = cache.size(); + + assertEquals(givenSize, cacheSize); + + cache.clear(); + + int clearedSize = cache.size(); + assertEquals(0, clearedSize); + } + + @Test + void metricsReturnsSnapshot() { + cache.close(); + cache = createCache(1); + byte[] value = "apple".getBytes(StandardCharsets.UTF_8); + + cache.put("fruit", value, ValueType.STRING, 0); + cache.get("fruit"); + cache.get("missing"); + + cache.put("color", value, ValueType.STRING, 0); + + Snapshot metrics = cache.metrics(); + + assertEquals(1, metrics.getHits()); + assertEquals(1, metrics.getMisses()); + assertEquals(1, metrics.getEvictions()); + assertEquals(0, metrics.getExpirations()); + assertEquals(0.5, metrics.getHitRate()); + } + + private LocalCache createCache(int capacity) { + return new LocalCache<>(capacity, new LruEvictionPolicy<>()); + } +} diff --git a/src/test/java/org/cache/eviction/LruEvictionPolicyTest.java b/src/test/java/org/cache/eviction/LruEvictionPolicyTest.java new file mode 100644 index 0000000..5601054 --- /dev/null +++ b/src/test/java/org/cache/eviction/LruEvictionPolicyTest.java @@ -0,0 +1,44 @@ +package org.cache.eviction; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LruEvictionPolicyTest { + + private LruEvictionPolicy evictionPolicy; + + @BeforeEach + void setUp() { + evictionPolicy = new LruEvictionPolicy<>(); + } + + @Test + void onKeyAddedMakesOldestKeyTheVictim() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + assertEquals("first", evictionPolicy.selectVictim().orElseThrow()); + } + + @Test + void onKeyAccessedMakesNotAccessedKeyTheVictim() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + evictionPolicy.onKeyAccessed("first"); + + assertEquals("second", evictionPolicy.selectVictim().orElseThrow()); + } + + @Test + void onKeyRemovedRemovesKeyFromVictimSelection() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + evictionPolicy.onKeyRemoved("first"); + + assertEquals("second", evictionPolicy.selectVictim().orElseThrow()); + } +} diff --git a/src/test/java/org/cache/eviction/MruEvictionPolicyTest.java b/src/test/java/org/cache/eviction/MruEvictionPolicyTest.java new file mode 100644 index 0000000..a47e087 --- /dev/null +++ b/src/test/java/org/cache/eviction/MruEvictionPolicyTest.java @@ -0,0 +1,44 @@ +package org.cache.eviction; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MruEvictionPolicyTest { + + private MruEvictionPolicy evictionPolicy; + + @BeforeEach + void setUp() { + evictionPolicy = new MruEvictionPolicy<>(); + } + + @Test + void onKeyAddedMakesNewestKeyTheVictim() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + assertEquals("second", evictionPolicy.selectVictim().orElseThrow()); + } + + @Test + void onKeyAccessedMakesAccessedKeyTheVictim() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + evictionPolicy.onKeyAccessed("first"); + + assertEquals("first", evictionPolicy.selectVictim().orElseThrow()); + } + + @Test + void onKeyRemovedRemovesKeyFromVictimSelection() { + evictionPolicy.onKeyAdded("first"); + evictionPolicy.onKeyAdded("second"); + + evictionPolicy.onKeyRemoved("second"); + + assertEquals("first", evictionPolicy.selectVictim().orElseThrow()); + } +} diff --git a/src/test/java/org/cache/network/TcpCacheServerTest.java b/src/test/java/org/cache/network/TcpCacheServerTest.java new file mode 100644 index 0000000..195d06b --- /dev/null +++ b/src/test/java/org/cache/network/TcpCacheServerTest.java @@ -0,0 +1,34 @@ +package org.cache.network; + +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TcpCacheServerTest { + + @Test + void startSubmitsHandlerForAcceptedSocket() throws Exception { + ServerSocket serverSocket = mock(ServerSocket.class); + Socket acceptedSocket = mock(Socket.class); + ExecutorService executor = mock(ExecutorService.class); + Runnable handler = mock(Runnable.class); + ClientConnectionHandlerFactory handlerFactory = mock(ClientConnectionHandlerFactory.class); + + when(serverSocket.getLocalPort()).thenReturn(2020); + when(serverSocket.isClosed()).thenReturn(false, true); + when(serverSocket.accept()).thenReturn(acceptedSocket); + when(handlerFactory.create(acceptedSocket)).thenReturn(handler); + var server = new TcpCacheServer(serverSocket, executor, handlerFactory); + + server.start(); + + verify(handlerFactory).create(acceptedSocket); + verify(executor).submit(handler); + } +} diff --git a/src/test/java/org/cache/network/connection/AutoProtocolConnectionTest.java b/src/test/java/org/cache/network/connection/AutoProtocolConnectionTest.java new file mode 100644 index 0000000..3576c99 --- /dev/null +++ b/src/test/java/org/cache/network/connection/AutoProtocolConnectionTest.java @@ -0,0 +1,64 @@ +package org.cache.network.connection; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AutoProtocolConnectionTest { + + @Test + void readsLineCommandAndWritesLineResponse() throws Exception { + var output = new ByteArrayOutputStream(); + var connection = connection("GET fruit\r\n", output); + + List command = connection.readCommand(); + connection.write("VALUE apple"); + + assertEquals(List.of("GET", "fruit"), command); + assertEquals("VALUE apple\r\n", output.toString(UTF_8)); + } + + @Test + void readsRespCommandAndWritesRespResponse() throws Exception { + var output = new ByteArrayOutputStream(); + var connection = connection("*2\r\n$3\r\nGET\r\n$5\r\nfruit\r\n", output); + + List command = connection.readCommand(); + connection.write("VALUE apple"); + + assertEquals(List.of("GET", "fruit"), command); + assertEquals("*2\r\n$5\r\nVALUE\r\n$5\r\napple\r\n", output.toString(UTF_8)); + } + + @Test + void switchesProtocolPerCommandOnSameConnection() throws Exception { + var output = new ByteArrayOutputStream(); + var input = "PUSH fruits apple\r\n*2\r\n$3\r\nGET\r\n$6\r\nfruits\r\n"; + var connection = connection(input, output); + + List lineCommand = connection.readCommand(); + connection.write("OK"); + List respCommand = connection.readCommand(); + connection.write("VALUE apple"); + + assertEquals(List.of("PUSH", "fruits", "apple"), lineCommand); + assertEquals(List.of("GET", "fruits"), respCommand); + assertEquals("OK\r\n*2\r\n$5\r\nVALUE\r\n$5\r\napple\r\n", output.toString(UTF_8)); + } + + private static AutoProtocolConnection connection(String inputValue, ByteArrayOutputStream output) throws Exception { + Socket socket = mock(Socket.class); + when(socket.getInputStream()).thenReturn(new ByteArrayInputStream(inputValue.getBytes(UTF_8))); + when(socket.getOutputStream()).thenReturn(output); + + return new AutoProtocolConnection(socket); + } +} diff --git a/src/test/java/org/cache/network/connection/ClientConnectionHandlerTest.java b/src/test/java/org/cache/network/connection/ClientConnectionHandlerTest.java new file mode 100644 index 0000000..2384e31 --- /dev/null +++ b/src/test/java/org/cache/network/connection/ClientConnectionHandlerTest.java @@ -0,0 +1,36 @@ +package org.cache.network.connection; + +import org.cache.protocol.CommandProcessor; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ClientConnectionHandlerTest { + + @Test + void runProcessesCommandAndWritesResponse() throws Exception { + Socket socket = mock(Socket.class); + CommandProcessor commandProcessor = mock(CommandProcessor.class); + var input = new ByteArrayInputStream("GET fruit\r\n".getBytes(UTF_8)); + var output = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(input); + when(socket.getOutputStream()).thenReturn(output); + when(commandProcessor.process(List.of("GET", "fruit"))).thenReturn("VALUE apple"); + + new ClientConnectionHandler(socket, commandProcessor).run(); + + verify(commandProcessor).process(List.of("GET", "fruit")); + verify(socket).close(); + assertEquals("VALUE apple\r\n", output.toString(UTF_8)); + } +} diff --git a/src/test/java/org/cache/network/connection/LineProtocolConnectionTest.java b/src/test/java/org/cache/network/connection/LineProtocolConnectionTest.java new file mode 100644 index 0000000..6abccac --- /dev/null +++ b/src/test/java/org/cache/network/connection/LineProtocolConnectionTest.java @@ -0,0 +1,58 @@ +package org.cache.network.connection; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LineProtocolConnectionTest { + + private LineProtocolConnection lineProtocolConnection; + private ByteArrayOutputStream output; + + @BeforeEach + void setup() throws Exception { + Socket socket = mock(Socket.class); + + var input = new ByteArrayInputStream("GET fruit\r\n".getBytes(UTF_8)); + output = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(input); + when(socket.getOutputStream()).thenReturn(output); + + lineProtocolConnection = new LineProtocolConnection(socket); + } + + @Test + void readCommandSplitsLineIntoParts() throws Exception { + List command = lineProtocolConnection.readCommand(); + + assertEquals(List.of("GET", "fruit"), command); + } + + @Test + void readLineReturnsLineWithoutCrlf() throws Exception { + String givenLine = lineProtocolConnection.readLine(); + + String expectedResponse = "GET fruit"; + + assertEquals(expectedResponse, givenLine); + } + + @Test + void writeAppendsCrlf() throws Exception { + String givenValue = "VALUE 333"; + + lineProtocolConnection.write(givenValue); + + assertEquals("VALUE 333\r\n", output.toString(UTF_8)); + } +} diff --git a/src/test/java/org/cache/network/connection/RespConnectionTest.java b/src/test/java/org/cache/network/connection/RespConnectionTest.java new file mode 100644 index 0000000..2c38bff --- /dev/null +++ b/src/test/java/org/cache/network/connection/RespConnectionTest.java @@ -0,0 +1,71 @@ +package org.cache.network.connection; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RespConnectionTest { + + @Test + void readCommandReadsRespArray() throws Exception { + var connection = connectionWithInput("*2\r\n$3\r\nGET\r\n$5\r\nfruit\r\n"); + + List command = connection.readCommand(); + + assertEquals(List.of("GET", "fruit"), command); + } + + @Test + void writeValueResponseAsRespArray() throws Exception { + var output = new ByteArrayOutputStream(); + var connection = connectionWithOutput(output); + + connection.write("VALUE apple"); + + assertEquals("*2\r\n$5\r\nVALUE\r\n$5\r\napple\r\n", output.toString(UTF_8)); + } + + @Test + void writeListResponseAsRespArrayOfValues() throws Exception { + var output = new ByteArrayOutputStream(); + var connection = connectionWithOutput(output); + + connection.write("LIST apple, banana"); + + assertEquals("*2\r\n$5\r\napple\r\n$6\r\nbanana\r\n", output.toString(UTF_8)); + } + + @Test + void writeEmptyListResponseAsEmptyRespArray() throws Exception { + var output = new ByteArrayOutputStream(); + var connection = connectionWithOutput(output); + + connection.write("LIST"); + + assertEquals("*0\r\n", output.toString(UTF_8)); + } + + private static RespConnection connectionWithInput(String inputValue) throws Exception { + return connection(new ByteArrayInputStream(inputValue.getBytes(UTF_8)), new ByteArrayOutputStream()); + } + + private static RespConnection connectionWithOutput(ByteArrayOutputStream output) throws Exception { + return connection(new ByteArrayInputStream(new byte[0]), output); + } + + private static RespConnection connection(ByteArrayInputStream input, ByteArrayOutputStream output) throws Exception { + Socket socket = mock(Socket.class); + when(socket.getInputStream()).thenReturn(input); + when(socket.getOutputStream()).thenReturn(output); + + return new RespConnection(socket); + } +} diff --git a/src/test/java/org/cache/protocol/CommandParserTest.java b/src/test/java/org/cache/protocol/CommandParserTest.java new file mode 100644 index 0000000..1df45c7 --- /dev/null +++ b/src/test/java/org/cache/protocol/CommandParserTest.java @@ -0,0 +1,134 @@ +package org.cache.protocol; + +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringKeyCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.cache.protocol.commands.CacheCommand; +import org.cache.protocol.commands.ClearCommand; +import org.cache.protocol.commands.DeleteCommand; +import org.cache.protocol.commands.GetCommand; +import org.cache.protocol.commands.InvalidCommand; +import org.cache.protocol.commands.LrangeCommand; +import org.cache.protocol.commands.MetricsCommand; +import org.cache.protocol.commands.PushCommand; +import org.cache.protocol.commands.PutCommand; +import org.cache.protocol.commands.SizeCommand; +import org.cache.protocol.commands.UnknownCommand; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +class CommandParserTest { + + private final CommandParser parser = new CommandParser<>(new StringKeyCodec()); + + @Test + void parseReturnsPutCommand() { + CacheCommand command = parser.parse(List.of("PUT", "fruit", "apple")); + + assertInstanceOf(PutCommand.class, command); + } + + @Test + void parseReturnsGetCommand() { + CacheCommand command = parser.parse(List.of("GET", "fruit")); + + assertInstanceOf(GetCommand.class, command); + } + + @Test + void parseReturnsDeleteCommand() { + CacheCommand command = parser.parse(List.of("DELETE", "fruit")); + + assertInstanceOf(DeleteCommand.class, command); + } + + @Test + void parseReturnsPushCommand() { + CacheCommand command = parser.parse(List.of("PUSH", "fruits", "apple")); + + assertInstanceOf(PushCommand.class, command); + } + + @Test + void parseReturnsLrangeCommandWithFromAndTo() { + CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "1", "3")); + + assertInstanceOf(LrangeCommand.class, command); + } + + @Test + void parseLrangeWithoutFromUsesZeroAsFromIndex() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana", "orange")), ValueType.LIST, 0); + CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "2")); + + String response = command.process(cache, valueCodecs); + + assertEquals("LIST apple, banana", response); + } + } + + @Test + void parseReturnsSizeCommand() { + CacheCommand command = parser.parse(List.of("SIZE")); + + assertInstanceOf(SizeCommand.class, command); + } + + @Test + void parseReturnsClearCommand() { + CacheCommand command = parser.parse(List.of("CLEAR")); + + assertInstanceOf(ClearCommand.class, command); + } + + @Test + void parseReturnsMetricsCommand() { + CacheCommand command = parser.parse(List.of("METRICS")); + + assertInstanceOf(MetricsCommand.class, command); + } + + @Test + void parseReturnsUnknownCommandForUnknownType() { + CacheCommand command = parser.parse(List.of("NOPE")); + + assertInstanceOf(UnknownCommand.class, command); + } + + @Test + void parseReturnsInvalidCommandForWrongArgumentCount() { + CacheCommand command = parser.parse(List.of("GET")); + + assertInstanceOf(InvalidCommand.class, command); + } + + @Test + void parseReturnsInvalidCommandForInvalidPutTtl() { + CacheCommand command = parser.parse(List.of("PUT", "fruit", "apple", "soon")); + + assertInstanceOf(InvalidCommand.class, command); + } + + @Test + void parseReturnsInvalidCommandForInvalidLrangeIndex() { + CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "start", "2")); + + assertInstanceOf(InvalidCommand.class, command); + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +} diff --git a/src/test/java/org/cache/protocol/CommandProcessorTest.java b/src/test/java/org/cache/protocol/CommandProcessorTest.java new file mode 100644 index 0000000..2844ab2 --- /dev/null +++ b/src/test/java/org/cache/protocol/CommandProcessorTest.java @@ -0,0 +1,35 @@ +package org.cache.protocol; + +import org.cache.core.Cache; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.cache.protocol.commands.CacheCommand; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CommandProcessorTest { + + @Test + void processParsesCommandAndReturnsCommandResponse() { + Cache cache = mock(Cache.class); + CommandParser parser = mock(CommandParser.class); + ValueCodecRegistry valueCodecs = mock(ValueCodecRegistry.class); + CacheCommand command = mock(CacheCommand.class); + List commandParts = List.of("GET", "fruit"); + + when(parser.parse(commandParts)).thenReturn(command); + when(command.process(cache, valueCodecs)).thenReturn("VALUE apple"); + var processor = new CommandProcessor<>(cache, parser, valueCodecs); + + String response = processor.process(commandParts); + + verify(parser).parse(commandParts); + verify(command).process(cache, valueCodecs); + assertEquals("VALUE apple", response); + } +} diff --git a/src/test/java/org/cache/protocol/codec/IntegerKeyCodecTest.java b/src/test/java/org/cache/protocol/codec/IntegerKeyCodecTest.java new file mode 100644 index 0000000..a20e2ad --- /dev/null +++ b/src/test/java/org/cache/protocol/codec/IntegerKeyCodecTest.java @@ -0,0 +1,34 @@ +package org.cache.protocol.codec; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class IntegerKeyCodecTest { + + private final IntegerKeyCodec codec = new IntegerKeyCodec(); + + @Test + void encodeConvertsIntegerToString() { + assertEquals("42", codec.encode(42)); + } + + @Test + void decodeConvertsNumericStringToInteger() { + assertEquals(42, codec.decode("42")); + } + + @Test + void encodeAndDecodeEmptyValuesReturnNull() { + assertNull(codec.encode(null)); + assertNull(codec.decode(null)); + assertNull(codec.decode(" ")); + } + + @Test + void decodeThrowsForNonNumericString() { + assertThrows(IllegalArgumentException.class, () -> codec.decode("fruit")); + } +} diff --git a/src/test/java/org/cache/protocol/codec/ListValueCodecTest.java b/src/test/java/org/cache/protocol/codec/ListValueCodecTest.java new file mode 100644 index 0000000..42dc3e6 --- /dev/null +++ b/src/test/java/org/cache/protocol/codec/ListValueCodecTest.java @@ -0,0 +1,37 @@ +package org.cache.protocol.codec; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ListValueCodecTest { + + private final ListValueCodec codec = new ListValueCodec(); + + @Test + void encodeAndDecodeRoundTripsList() { + List value = List.of("apple", "banana"); + + List decoded = codec.decode(codec.encode(value)); + + assertEquals(value, decoded); + } + + @Test + void encodeAndDecodeRoundTripsEmptyList() { + List value = List.of(); + + List decoded = codec.decode(codec.encode(value)); + + assertEquals(value, decoded); + } + + @Test + void toStringJoinsListValues() { + byte[] encoded = codec.encode(List.of("apple", "banana")); + + assertEquals("apple, banana", codec.toString(encoded)); + } +} diff --git a/src/test/java/org/cache/protocol/codec/StringKeyCodecTest.java b/src/test/java/org/cache/protocol/codec/StringKeyCodecTest.java new file mode 100644 index 0000000..7cd7aeb --- /dev/null +++ b/src/test/java/org/cache/protocol/codec/StringKeyCodecTest.java @@ -0,0 +1,27 @@ +package org.cache.protocol.codec; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class StringKeyCodecTest { + + private final StringKeyCodec codec = new StringKeyCodec(); + + @Test + void encodeReturnsSameValue() { + assertEquals("fruit", codec.encode("fruit")); + } + + @Test + void decodeReturnsSameValue() { + assertEquals("fruit", codec.decode("fruit")); + } + + @Test + void encodeAndDecodeAllowNull() { + assertNull(codec.encode(null)); + assertNull(codec.decode(null)); + } +} diff --git a/src/test/java/org/cache/protocol/codec/StringValueCodecTest.java b/src/test/java/org/cache/protocol/codec/StringValueCodecTest.java new file mode 100644 index 0000000..9be1e1a --- /dev/null +++ b/src/test/java/org/cache/protocol/codec/StringValueCodecTest.java @@ -0,0 +1,34 @@ +package org.cache.protocol.codec; + +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StringValueCodecTest { + + private final StringValueCodec codec = new StringValueCodec(); + + @Test + void encodeConvertsStringToUtf8Bytes() { + assertArrayEquals("apple".getBytes(UTF_8), codec.encode("apple")); + } + + @Test + void decodeConvertsUtf8BytesToString() { + assertEquals("apple", codec.decode("apple".getBytes(UTF_8))); + } + + @Test + void toStringConvertsUtf8BytesToString() { + assertEquals("apple", codec.toString("apple".getBytes(UTF_8))); + } + + @Test + void encodeAndDecodeThrowForNull() { + assertThrows(CodecConversionException.class, () -> codec.encode(null)); + assertThrows(CodecConversionException.class, () -> codec.decode(null)); + } +} diff --git a/src/test/java/org/cache/protocol/codec/ValueCodecRegistryTest.java b/src/test/java/org/cache/protocol/codec/ValueCodecRegistryTest.java new file mode 100644 index 0000000..6585eb8 --- /dev/null +++ b/src/test/java/org/cache/protocol/codec/ValueCodecRegistryTest.java @@ -0,0 +1,36 @@ +package org.cache.protocol.codec; + +import org.cache.core.ValueType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ValueCodecRegistryTest { + + @Test + void getReturnsRegisteredCodec() { + var registry = new ValueCodecRegistry(); + var codec = new StringValueCodec(); + + registry.register(ValueType.STRING, codec); + + assertSame(codec, registry.get(ValueType.STRING)); + } + + @Test + void registerReturnsSameRegistryForChaining() { + var registry = new ValueCodecRegistry(); + + ValueCodecRegistry returned = registry.register(ValueType.STRING, new StringValueCodec()); + + assertSame(registry, returned); + } + + @Test + void getThrowsForUnsupportedValueType() { + var registry = new ValueCodecRegistry(); + + assertThrows(IllegalArgumentException.class, () -> registry.get(ValueType.STRING)); + } +} diff --git a/src/test/java/org/cache/protocol/commands/ClearCommandTest.java b/src/test/java/org/cache/protocol/commands/ClearCommandTest.java new file mode 100644 index 0000000..8536781 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/ClearCommandTest.java @@ -0,0 +1,35 @@ +package org.cache.protocol.commands; + +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ClearCommandTest { + + + @Test + void processClearsCacheAndReturnsOk() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + ValueCodecRegistry codecRegistry = new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()); + + cache.put("x", "123".getBytes(StandardCharsets.UTF_8), ValueType.STRING, 10000); + + assertEquals(1, cache.size()); + + var clearCommand = new ClearCommand(); + + String response = clearCommand.process(cache, codecRegistry); + + assertEquals(ResponseConstants.OK.name(), response); + assertEquals(0, cache.size()); + } + } +} diff --git a/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java b/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java new file mode 100644 index 0000000..3b78320 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java @@ -0,0 +1,23 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class DeleteCommandTest { + + @Test + void processDeletesKeyAndReturnsOk() { + Cache cache = mock(Cache.class); + var command = new DeleteCommand<>("fruit"); + + String response = command.process(cache, new ValueCodecRegistry()); + + verify(cache).delete("fruit"); + assertEquals(ResponseConstants.OK.name(), response); + } +} diff --git a/src/test/java/org/cache/protocol/commands/GetCommandTest.java b/src/test/java/org/cache/protocol/commands/GetCommandTest.java new file mode 100644 index 0000000..3eb7e65 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/GetCommandTest.java @@ -0,0 +1,59 @@ +package org.cache.protocol.commands; + +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GetCommandTest { + + @Test + void processReturnsValueForStringEntry() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruit", new StringValueCodec().encode("apple"), ValueType.STRING, 0); + var command = new GetCommand("fruit"); + + String response = command.process(cache, valueCodecs); + + assertEquals("VALUE apple", response); + } + } + + @Test + void processReturnsNotFoundForMissingKey() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var command = new GetCommand("missing"); + + String response = command.process(cache, valueCodecs()); + + assertEquals(ResponseConstants.NOT_FOUND.name(), response); + } + } + + @Test + void processReturnsErrorForListEntry() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruit", new ListValueCodec().encode(List.of("apple")), ValueType.LIST, 0); + var command = new GetCommand("fruit"); + + String response = command.process(cache, valueCodecs); + + assertEquals("ERROR key contains list value", response); + } + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +} diff --git a/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java b/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java new file mode 100644 index 0000000..6a442aa --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java @@ -0,0 +1,20 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class InvalidCommandTest { + + @Test + void processReturnsErrorWithMessage() { + var command = new InvalidCommand("usage: GET key"); + + String response = command.process(mock(Cache.class), new ValueCodecRegistry()); + + assertEquals("ERROR usage: GET key", response); + } +} diff --git a/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java b/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java new file mode 100644 index 0000000..05acc47 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java @@ -0,0 +1,96 @@ +package org.cache.protocol.commands; + +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LrangeCommandTest { + + @Test + void processReturnsListRange() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana", "orange")), ValueType.LIST, 0); + var command = new LrangeCommand("fruits", 0, 2); + + String response = command.process(cache, valueCodecs); + + assertEquals("LIST apple, banana", response); + } + } + + @Test + void processBoundsToIndexToListSize() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana")), ValueType.LIST, 0); + var command = new LrangeCommand("fruits", 0, 10); + + String response = command.process(cache, valueCodecs); + + assertEquals("LIST apple, banana", response); + } + } + + @Test + void processReturnsEmptyListWhenFromIsOutOfRange() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new ListValueCodec().encode(List.of("apple")), ValueType.LIST, 0); + var command = new LrangeCommand("fruits", 2, 3); + + String response = command.process(cache, valueCodecs); + + assertEquals(ResponseConstants.LIST.name(), response); + } + } + + @Test + void processReturnsNotFoundForMissingKey() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var command = new LrangeCommand("missing", 0, 1); + + String response = command.process(cache, valueCodecs()); + + assertEquals(ResponseConstants.NOT_FOUND.name(), response); + } + } + + @Test + void processReturnsErrorForInvalidRange() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var command = new LrangeCommand("fruits", 2, 1); + + String response = command.process(cache, valueCodecs()); + + assertEquals("ERROR invalid range: from must be >= 0 and to must be >= from", response); + } + } + + @Test + void processReturnsErrorWhenKeyContainsString() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new StringValueCodec().encode("apple"), ValueType.STRING, 0); + var command = new LrangeCommand("fruits", 0, 1); + + String response = command.process(cache, valueCodecs); + + assertEquals("ERROR key contains string value", response); + } + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +} diff --git a/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java b/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java new file mode 100644 index 0000000..f5e240d --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java @@ -0,0 +1,24 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.core.metrics.Snapshot; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MetricsCommandTest { + + @Test + void processReturnsMetricsSnapshotValues() { + Cache cache = mock(Cache.class); + when(cache.metrics()).thenReturn(new Snapshot(1, 2, 3, 4, 0.5)); + var command = new MetricsCommand(); + + String response = command.process(cache, new ValueCodecRegistry()); + + assertEquals("METRICS hits=1 misses=2 evictions=3 expirations=4 hitRate=0.5", response); + } +} diff --git a/src/test/java/org/cache/protocol/commands/PushCommandTest.java b/src/test/java/org/cache/protocol/commands/PushCommandTest.java new file mode 100644 index 0000000..6e11fa7 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/PushCommandTest.java @@ -0,0 +1,59 @@ +package org.cache.protocol.commands; + +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PushCommandTest { + + @Test + void processCreatesListAndReturnsOk() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var command = new PushCommand<>("fruits", "apple", ValueType.LIST); + + String response = command.process(cache, valueCodecs()); + + assertEquals(ResponseConstants.OK.name(), response); + assertEquals("LIST apple", new LrangeCommand("fruits", 0, 1).process(cache, valueCodecs())); + } + } + + @Test + void processAppendsToExistingList() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + new PushCommand("fruits", "apple", ValueType.LIST).process(cache, valueCodecs); + var command = new PushCommand("fruits", "banana", ValueType.LIST); + + String response = command.process(cache, valueCodecs); + + assertEquals(ResponseConstants.OK.name(), response); + assertEquals("LIST apple, banana", new LrangeCommand("fruits", 0, 2).process(cache, valueCodecs)); + } + } + + @Test + void processReturnsErrorWhenKeyContainsString() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + var valueCodecs = valueCodecs(); + cache.put("fruits", new StringValueCodec().encode("apple"), ValueType.STRING, 0); + var command = new PushCommand("fruits", "banana", ValueType.LIST); + + String response = command.process(cache, valueCodecs); + + assertEquals("ERROR key contains string value", response); + } + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +} diff --git a/src/test/java/org/cache/protocol/commands/PutCommandTest.java b/src/test/java/org/cache/protocol/commands/PutCommandTest.java new file mode 100644 index 0000000..72d7044 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/PutCommandTest.java @@ -0,0 +1,33 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.core.ValueType; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class PutCommandTest { + + @Test + void processStoresEncodedStringValueAndReturnsOk() { + Cache cache = mock(Cache.class); + var valueCodecs = new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()); + var command = new PutCommand<>("fruit", "apple", ValueType.STRING, 1_000); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(byte[].class); + + String response = command.process(cache, valueCodecs); + + verify(cache).put(eq("fruit"), valueCaptor.capture(), eq(ValueType.STRING), eq(1_000L)); + assertArrayEquals("apple".getBytes(UTF_8), valueCaptor.getValue()); + assertEquals(ResponseConstants.OK.name(), response); + } +} diff --git a/src/test/java/org/cache/protocol/commands/SizeCommandTest.java b/src/test/java/org/cache/protocol/commands/SizeCommandTest.java new file mode 100644 index 0000000..d95f407 --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/SizeCommandTest.java @@ -0,0 +1,23 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SizeCommandTest { + + @Test + void processReturnsCacheSize() { + Cache cache = mock(Cache.class); + when(cache.size()).thenReturn(3); + var command = new SizeCommand(); + + String response = command.process(cache, new ValueCodecRegistry()); + + assertEquals("SIZE 3", response); + } +} diff --git a/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java b/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java new file mode 100644 index 0000000..448841c --- /dev/null +++ b/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java @@ -0,0 +1,20 @@ +package org.cache.protocol.commands; + +import org.cache.core.Cache; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class UnknownCommandTest { + + @Test + void processReturnsUnknownCommandError() { + var command = new UnknownCommand(); + + String response = command.process(mock(Cache.class), new ValueCodecRegistry()); + + assertEquals("ERROR unknown command", response); + } +}