All fields map directly to environment variables consumed by
+ * {@code riptidekv-server}:
+ *
+ *
/riptidekv}.
+ */
+ public Builder dataDir(Path dataDir) {
+ if (dataDir == null) throw new IllegalArgumentException("dataDir must not be null");
+ this.dataDir = dataDir;
+ return this;
+ }
+
+ /**
+ * Memtable flush threshold in KiB. When the in-memory write buffer
+ * reaches this size, its contents are flushed to a new immutable
+ * SSTable on disk. Larger values mean fewer flushes and more RAM
+ * usage; smaller values mean more frequent disk writes.
+ * Default: {@code 1024} (= 1 MiB).
+ */
+ public Builder flushKb(int flushKb) {
+ if (flushKb <= 0) throw new IllegalArgumentException("flushKb must be > 0");
+ this.flushKb = flushKb;
+ return this;
+ }
+
+ /**
+ * Whether to call {@code fsync} after every WAL write.
+ * {@code true} (default) — fully durable; every acknowledged write
+ * survives a power loss or OS crash.
+ * {@code false} — up to ~1 second of writes may be lost on a hard
+ * crash, but throughput is significantly higher. Safe for
+ * ephemeral/test data.
+ */
+ public Builder walSync(boolean walSync) {
+ this.walSync = walSync;
+ return this;
+ }
+
+ /** Build the immutable {@link RiptideKVConfig}. */
+ public RiptideKVConfig build() {
+ int colon = bind.lastIndexOf(':');
+ if (colon < 0) {
+ throw new IllegalArgumentException(
+ "bind must be in host:port format (no colon found): " + bind);
+ }
+ try {
+ int port = Integer.parseInt(bind.substring(colon + 1));
+ if (port < 0 || port > 65535) {
+ throw new IllegalArgumentException(
+ "bind port must be in [0, 65535]: " + port);
+ }
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "bind port is not a valid integer: " + bind, e);
+ }
+ return new RiptideKVConfig(this);
+ }
+ }
+}
diff --git a/java/src/main/java/io/riptidekv/RiptideKVServer.java b/java/src/main/java/io/riptidekv/RiptideKVServer.java
new file mode 100644
index 0000000..258265d
--- /dev/null
+++ b/java/src/main/java/io/riptidekv/RiptideKVServer.java
@@ -0,0 +1,305 @@
+package io.riptidekv;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Manages the lifecycle of an embedded RiptideKV server process.
+ *
+ * How it works
+ *
+ * - On {@link #start()}, the platform-specific {@code riptidekv-server}
+ * binary is extracted from the JAR's {@code /native/<os>-<arch>/}
+ * classpath resource to a temporary file.
+ * - The binary is launched as a child {@link Process} with the configured
+ * environment variables.
+ * - {@link #start()} blocks until the server accepts TCP connections
+ * (up to 10 seconds), then returns.
+ * - Any Redis client (Jedis, lettuce, redis-py, redis-cli, go-redis) can
+ * connect to {@code 127.0.0.1:} and issue commands.
+ * - {@link #close()} sends SIGTERM and waits for a clean shutdown.
+ *
+ *
+ * Quick start — plain Java
+ * {@code
+ * RiptideKVConfig cfg = RiptideKVConfig.builder()
+ * .bind("127.0.0.1:6379")
+ * .dataDir(Paths.get("/var/lib/myapp/rkv"))
+ * .build();
+ *
+ * try (RiptideKVServer server = new RiptideKVServer(cfg)) {
+ * server.start();
+ * // now talk to it with any Redis client:
+ * try (Jedis j = new Jedis("127.0.0.1", server.getPort())) {
+ * j.set("hello", "world");
+ * System.out.println(j.get("hello")); // world
+ * }
+ * } // server shuts down here
+ * }
+ *
+ * Quick start — Spring Boot test
+ * {@code
+ * @BeforeAll
+ * static void startKv() throws IOException {
+ * server = new RiptideKVServer(
+ * RiptideKVConfig.builder()
+ * .bind("127.0.0.1:16379")
+ * .walSync(false) // fast for tests
+ * .build());
+ * server.start();
+ * }
+ *
+ * @AfterAll
+ * static void stopKv() { server.close(); }
+ * }
+ *
+ * Supported platforms
+ *
+ * - Linux x86_64
+ * - Linux aarch64 (when included in the release)
+ * - macOS x86_64 (Intel)
+ * - macOS aarch64 (Apple Silicon)
+ * - Windows x86_64
+ *
+ */
+public final class RiptideKVServer implements AutoCloseable {
+
+ private final RiptideKVConfig config;
+
+ private volatile Process process;
+ private volatile Path extractedBinary;
+
+ /**
+ * Create a server manager with the given configuration.
+ * The server is not started until {@link #start()} is called.
+ */
+ public RiptideKVServer(RiptideKVConfig config) {
+ if (config == null) throw new IllegalArgumentException("config must not be null");
+ this.config = config;
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ /**
+ * Extract the native binary, create the data directory, launch the server
+ * process, and block until it is accepting TCP connections.
+ *
+ * @throws IOException if the binary cannot be extracted, the
+ * process fails to start, or it does not
+ * become ready within 10 seconds.
+ * @throws IllegalStateException if the server is already running.
+ */
+ public void start() throws IOException {
+ if (process != null && process.isAlive()) {
+ throw new IllegalStateException("RiptideKV server is already running (pid=" + process.pid() + ")");
+ }
+
+ extractedBinary = extractBinary();
+
+ // Create data directory layout expected by the server.
+ Path dataDir = config.getDataDir();
+ Path walPath = dataDir.resolve("wal.log");
+ Path sstDir = dataDir.resolve("sst");
+ Files.createDirectories(sstDir);
+
+ ProcessBuilder pb = new ProcessBuilder(extractedBinary.toString());
+ pb.environment().put("RIPTIDE_BIND", config.getBind());
+ pb.environment().put("RIPTIDE_WAL_PATH", walPath.toString());
+ pb.environment().put("RIPTIDE_SST_DIR", sstDir.toString());
+ pb.environment().put("RIPTIDE_FLUSH_KB", String.valueOf(config.getFlushKb()));
+ pb.environment().put("RIPTIDE_WAL_SYNC", config.isWalSync() ? "true" : "false");
+
+ // Redirect server stderr/stdout to /dev/null by default.
+ // Override by calling pb.inheritIO() before start() if you need logs.
+ pb.redirectErrorStream(true);
+ pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
+
+ process = pb.start();
+
+ waitUntilReady(10_000);
+ }
+
+ /**
+ * Returns the TCP port the server is listening on.
+ * Derived from the configured {@code bind} address.
+ */
+ public int getPort() {
+ return config.getPort();
+ }
+
+ /**
+ * Returns the full bind address string, e.g. {@code "127.0.0.1:6379"}.
+ */
+ public String getBind() {
+ return config.getBind();
+ }
+
+ /**
+ * Returns {@code true} if the server process is currently running.
+ */
+ public boolean isRunning() {
+ return process != null && process.isAlive();
+ }
+
+ /**
+ * Terminate the server.
+ *
+ * Sends SIGTERM (graceful shutdown — flushes the memtable to disk),
+ * then waits up to 5 seconds for the process to exit. If it does not
+ * exit in time, {@code SIGKILL} is sent.
+ *
+ *
Safe to call multiple times; subsequent calls are no-ops.
+ */
+ @Override
+ public void close() {
+ Process p = process;
+ if (p == null || !p.isAlive()) return;
+
+ p.destroy(); // SIGTERM — gives the server a chance to flush
+ try {
+ if (!p.waitFor(5, TimeUnit.SECONDS)) {
+ p.destroyForcibly(); // SIGKILL — no mercy
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ p.destroyForcibly();
+ } finally {
+ // Delete the extracted temp binary.
+ Path bin = extractedBinary;
+ if (bin != null) {
+ try { Files.deleteIfExists(bin); } catch (IOException ignored) {}
+ }
+ }
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Detect the current platform, find the matching resource inside the JAR,
+ * copy it to a temp file, and mark it executable.
+ */
+ private Path extractBinary() throws IOException {
+ String platform = detectPlatform();
+ boolean isWin = platform.startsWith("windows");
+ String ext = isWin ? ".exe" : "";
+ String resource = "/native/" + platform + "/riptidekv-server" + ext;
+
+ InputStream in = RiptideKVServer.class.getResourceAsStream(resource);
+ if (in == null) {
+ throw new IOException(
+ "RiptideKV native binary not bundled for platform '" + platform + "'.\n" +
+ "Looked for classpath resource: " + resource + "\n" +
+ "Supported platforms: linux-x86_64, linux-aarch64, " +
+ "macos-x86_64, macos-aarch64, windows-x86_64.\n" +
+ "Make sure you are using the official riptidekv-server JAR from " +
+ "GitHub Packages, not a locally-built snapshot without binaries."
+ );
+ }
+
+ Path tmp = Files.createTempFile("riptidekv-server-", ext.isEmpty() ? "" : ext);
+ tmp.toFile().deleteOnExit();
+
+ try (InputStream src = in) {
+ Files.copy(src, tmp, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ if (!isWin) {
+ // chmod +x so the OS will actually execute it.
+ if (!tmp.toFile().setExecutable(true, true)) {
+ throw new IOException("Failed to set executable bit on: " + tmp);
+ }
+ }
+
+ return tmp;
+ }
+
+ /**
+ * Derive the platform key used as the resource directory name.
+ * E.g. {@code "linux-x86_64"}, {@code "macos-aarch64"}, {@code "windows-x86_64"}.
+ */
+ private static String detectPlatform() {
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT);
+
+ String osKey;
+ if (os.contains("linux")) {
+ osKey = "linux";
+ } else if (os.contains("mac") || os.contains("darwin")) {
+ osKey = "macos";
+ } else if (os.contains("windows")) {
+ osKey = "windows";
+ } else {
+ throw new UnsupportedOperationException(
+ "Unsupported operating system: '" + System.getProperty("os.name") + "'. " +
+ "RiptideKV supports Linux, macOS, and Windows.");
+ }
+
+ String archKey;
+ if (arch.equals("amd64") || arch.equals("x86_64")) {
+ archKey = "x86_64";
+ } else if (arch.equals("aarch64") || arch.equals("arm64")) {
+ archKey = "aarch64";
+ } else {
+ throw new UnsupportedOperationException(
+ "Unsupported CPU architecture: '" + System.getProperty("os.arch") + "'. " +
+ "RiptideKV supports x86_64 (amd64) and aarch64 (arm64).");
+ }
+
+ return osKey + "-" + archKey;
+ }
+
+ /**
+ * Poll the server's TCP port until a connection succeeds or we time out.
+ *
+ * @param timeoutMs maximum wait time in milliseconds
+ * @throws IOException if the process dies early or the timeout expires
+ */
+ private void waitUntilReady(long timeoutMs) throws IOException {
+ // Determine host to probe from the bind address.
+ String bindHost = config.getBind();
+ int colon = bindHost.lastIndexOf(':');
+ String host = bindHost.substring(0, colon);
+ int port = config.getPort();
+
+ // "0.0.0.0" means "all interfaces" — probe loopback.
+ if (host.equals("0.0.0.0") || host.isEmpty()) {
+ host = "127.0.0.1";
+ }
+
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ IOException lastError = null;
+
+ while (System.currentTimeMillis() < deadline) {
+ // Check the process hasn't already died.
+ if (!process.isAlive()) {
+ throw new IOException(
+ "RiptideKV server process exited unexpectedly before becoming ready " +
+ "(exit code: " + process.exitValue() + "). " +
+ "Check that the data directory is writable: " + config.getDataDir());
+ }
+
+ try (Socket socket = new Socket(host, port)) {
+ return; // TCP handshake succeeded — server is accepting connections
+ } catch (IOException e) {
+ lastError = e;
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for RiptideKV to start", ie);
+ }
+ }
+ }
+
+ close(); // clean up the zombie process
+ throw new IOException(
+ "RiptideKV server did not become ready within " + timeoutMs + " ms on " +
+ config.getBind() + ". Last connection error: " + lastError);
+ }
+}
diff --git a/java/src/test/java/io/riptidekv/RespClient.java b/java/src/test/java/io/riptidekv/RespClient.java
new file mode 100644
index 0000000..425758f
--- /dev/null
+++ b/java/src/test/java/io/riptidekv/RespClient.java
@@ -0,0 +1,150 @@
+package io.riptidekv;
+
+import java.io.*;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Minimal RESP2 client for tests only.
+ * Reads responses at the byte level so binary values are handled correctly.
+ */
+class RespClient implements AutoCloseable {
+
+ private final Socket socket;
+ private final OutputStream out;
+ private final InputStream in;
+
+ RespClient(int port) throws IOException {
+ this("127.0.0.1", port);
+ }
+
+ RespClient(String host, int port) throws IOException {
+ socket = new Socket(host, port);
+ socket.setSoTimeout(5_000);
+ out = new BufferedOutputStream(socket.getOutputStream());
+ in = socket.getInputStream();
+ }
+
+ // ── Send ─────────────────────────────────────────────────────────────────
+
+ /** Write a RESP2 inline array command. */
+ void send(String... args) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ sb.append('*').append(args.length).append("\r\n");
+ for (String arg : args) {
+ byte[] b = arg.getBytes(StandardCharsets.UTF_8);
+ sb.append('$').append(b.length).append("\r\n").append(arg).append("\r\n");
+ }
+ out.write(sb.toString().getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ }
+
+ // ── Receive ───────────────────────────────────────────────────────────────
+
+ /**
+ * Read one RESP2 value. Returns:
+ *
+ * - {@code String} — simple string (+) or bulk string ($)
+ *
- {@code Long} — integer (:)
+ *
- {@code Object[]} — array (*)
+ *
- {@code null} — null bulk ($-1) or null array (*-1)
+ *
- {@link RespError}— error (-)
+ *
+ */
+ Object recv() throws IOException {
+ String line = readLine();
+ char type = line.charAt(0);
+ String payload = line.substring(1);
+
+ return switch (type) {
+ case '+' -> payload;
+ case '-' -> new RespError(payload);
+ case ':' -> Long.parseLong(payload);
+ case '$' -> {
+ int len = Integer.parseInt(payload);
+ if (len == -1) yield null;
+ byte[] buf = in.readNBytes(len);
+ in.readNBytes(2); // CRLF
+ yield new String(buf, StandardCharsets.UTF_8);
+ }
+ case '*' -> {
+ int count = Integer.parseInt(payload);
+ if (count == -1) yield null;
+ Object[] arr = new Object[count];
+ for (int i = 0; i < count; i++) arr[i] = recv();
+ yield arr;
+ }
+ default -> throw new IOException("Unknown RESP prefix '" + type + "' in: " + line);
+ };
+ }
+
+ // ── Typed receive helpers ─────────────────────────────────────────────────
+
+ /** Assert the next response is a simple string; return it. */
+ String recvSimple() throws IOException {
+ Object r = recv();
+ if (r instanceof String s) return s;
+ throw new AssertionError("Expected simple string, got: " + r);
+ }
+
+ /** Assert the next response is +OK. */
+ void recvOk() throws IOException {
+ String s = recvSimple();
+ if (!"OK".equals(s)) throw new AssertionError("Expected OK, got: " + s);
+ }
+
+ /** Read a bulk string; may be null (null bulk reply). */
+ String recvBulk() throws IOException {
+ Object r = recv();
+ if (r == null || r instanceof String) return (String) r;
+ throw new AssertionError("Expected bulk string, got: " + r);
+ }
+
+ /** Read an integer reply. */
+ long recvInt() throws IOException {
+ Object r = recv();
+ if (r instanceof Long l) return l;
+ throw new AssertionError("Expected integer, got: " + r);
+ }
+
+ /** Read an array reply; may be null. */
+ Object[] recvArray() throws IOException {
+ Object r = recv();
+ if (r == null || r instanceof Object[]) return (Object[]) r;
+ throw new AssertionError("Expected array, got: " + r);
+ }
+
+ /** Read an error reply. */
+ RespError recvError() throws IOException {
+ Object r = recv();
+ if (r instanceof RespError e) return e;
+ throw new AssertionError("Expected error, got: " + r);
+ }
+
+ // ── Low-level ─────────────────────────────────────────────────────────────
+
+ private String readLine() throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream(64);
+ int b;
+ while ((b = in.read()) != -1) {
+ if (b == '\r') {
+ in.read(); // consume \n
+ return baos.toString(StandardCharsets.UTF_8);
+ }
+ baos.write(b);
+ }
+ throw new EOFException("Server closed connection mid-line");
+ }
+
+ @Override
+ public void close() throws IOException {
+ socket.close();
+ }
+
+ // ── Error wrapper ─────────────────────────────────────────────────────────
+
+ record RespError(String message) {
+ boolean startsWith(String prefix) { return message.startsWith(prefix); }
+ @Override public String toString() { return "-" + message; }
+ }
+}
diff --git a/java/src/test/java/io/riptidekv/RespCommandsTest.java b/java/src/test/java/io/riptidekv/RespCommandsTest.java
new file mode 100644
index 0000000..e503e5a
--- /dev/null
+++ b/java/src/test/java/io/riptidekv/RespCommandsTest.java
@@ -0,0 +1,1064 @@
+package io.riptidekv;
+
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * End-to-end integration tests for every RESP2 command supported by RiptideKV.
+ *
+ * One server is started per outer-class lifecycle ({@code @BeforeAll}/{@code @AfterAll}).
+ * A fresh {@link RespClient} is opened before each test and {@code FLUSHALL} is called
+ * so each test starts with an empty keyspace.
+ *
+ *
Commands are grouped into {@code @Nested} classes by category:
+ *
+ * - {@link ConnectionTests} — PING, ECHO, SELECT, HELLO, CLIENT, INFO, CONFIG, COMMAND, QUIT
+ * - {@link DatabaseTests} — DBSIZE, FLUSHDB, FLUSHALL, ACL, SLOWLOG, MEMORY, WAIT
+ * - {@link StringTests} — GET, SET (all options), SETNX, SETEX, PSETEX, GETSET, GETDEL,
+ * GETEX, MGET, MSET, MSETNX, APPEND, STRLEN, INCR, INCRBY,
+ * INCRBYFLOAT, DECR, DECRBY, GETRANGE, SETRANGE
+ * - {@link KeyTests} — DEL, UNLINK, EXISTS, TYPE, RENAME, RENAMENX, RANDOMKEY, TOUCH,
+ * EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL, PERSIST,
+ * EXPIRETIME, PEXPIRETIME, KEYS, SCAN
+ * - {@link ExpiryTests} — real-time TTL expiry behaviour (uses Thread.sleep)
+ * - {@link EdgeCaseTests} — pipelining, concurrent clients, binary-safe values, unknown cmd
+ *
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class RespCommandsTest {
+
+ RiptideKVServer server;
+ int port;
+ RespClient c;
+
+ // ── Suite lifecycle ───────────────────────────────────────────────────────
+
+ @BeforeAll
+ void startServer(@TempDir Path tempDir) throws IOException {
+ port = freePort();
+ server = new RiptideKVServer(
+ RiptideKVConfig.builder()
+ .bind("127.0.0.1:" + port)
+ .dataDir(tempDir)
+ .walSync(false)
+ .build());
+ server.start();
+ }
+
+ @AfterAll
+ void stopServer() {
+ if (server != null) server.close();
+ }
+
+ @BeforeEach
+ void openClientAndFlush() throws IOException {
+ c = new RespClient(port);
+ c.send("FLUSHALL");
+ assertEquals("OK", c.recvSimple());
+ }
+
+ @AfterEach
+ void closeClient() throws IOException {
+ if (c != null) c.close();
+ }
+
+ // ── Shared helpers ────────────────────────────────────────────────────────
+
+ static int freePort() throws IOException {
+ try (var ss = new ServerSocket(0)) { return ss.getLocalPort(); }
+ }
+
+ /** Convenience: SET k v and assert +OK. */
+ void set(String k, String v) throws IOException {
+ c.send("SET", k, v);
+ c.recvOk();
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // CONNECTION / SERVER COMMANDS
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class ConnectionTests {
+
+ @Test void ping_noArgs_returnsPong() throws IOException {
+ c.send("PING");
+ assertEquals("PONG", c.recvSimple());
+ }
+
+ @Test void ping_withMessage_returnsMessage() throws IOException {
+ c.send("PING", "hello world");
+ assertEquals("hello world", c.recvBulk());
+ }
+
+ @Test void echo_returnsArgument() throws IOException {
+ c.send("ECHO", "foobar");
+ assertEquals("foobar", c.recvBulk());
+ }
+
+ @Test void echo_emptyString() throws IOException {
+ c.send("ECHO", "");
+ assertEquals("", c.recvBulk());
+ }
+
+ @Test void select_zero_returnsOk() throws IOException {
+ c.send("SELECT", "0");
+ c.recvOk();
+ }
+
+ @Test void select_nonZero_returnsError() throws IOException {
+ c.send("SELECT", "1");
+ assertTrue(c.recvError().startsWith("ERR"));
+ }
+
+ @Test void hello_resp2_returnsResponse() throws IOException {
+ c.send("HELLO", "2");
+ // RiptideKV returns a bulk string for HELLO 2
+ Object r = c.recv();
+ assertNotNull(r);
+ }
+
+ @Test void hello_resp3_returnsError() throws IOException {
+ c.send("HELLO", "3");
+ // RiptideKV returns NOPROTO (not ERR) for unsupported protocol versions
+ var err = c.recvError();
+ assertTrue(err.startsWith("NOPROTO") || err.startsWith("ERR"),
+ "HELLO 3 should return a NOPROTO or ERR error, got: " + err);
+ }
+
+ @Test void client_setname_returnsOk() throws IOException {
+ c.send("CLIENT", "SETNAME", "myapp");
+ c.recvOk();
+ }
+
+ @Test void client_getname_returnsSetName() throws IOException {
+ c.send("CLIENT", "SETNAME", "testclient");
+ c.recvOk();
+ c.send("CLIENT", "GETNAME");
+ assertEquals("testclient", c.recvBulk());
+ }
+
+ @Test void client_id_returnsInteger() throws IOException {
+ c.send("CLIENT", "ID");
+ long id = c.recvInt();
+ assertTrue(id >= 0, "CLIENT ID should be non-negative");
+ }
+
+ @Test void command_count_returnsPositiveInteger() throws IOException {
+ c.send("COMMAND", "COUNT");
+ long count = c.recvInt();
+ assertTrue(count > 10, "Expected at least 10 commands, got: " + count);
+ }
+
+ @Test void info_returnsNonEmptyBulkString() throws IOException {
+ c.send("INFO");
+ String info = c.recvBulk();
+ assertNotNull(info);
+ assertFalse(info.isBlank());
+ }
+
+ @Test void info_serverSection_containsVersionField() throws IOException {
+ c.send("INFO", "server");
+ String info = c.recvBulk();
+ assertNotNull(info);
+ assertTrue(info.contains("redis_version") || info.contains("riptidekv"),
+ "INFO server should contain version info: " + info);
+ }
+
+ @Test void config_get_returnsArray() throws IOException {
+ c.send("CONFIG", "GET", "*");
+ Object r = c.recv();
+ assertNotNull(r);
+ // RiptideKV returns an empty array for CONFIG GET
+ assertTrue(r instanceof Object[]);
+ }
+
+ @Test void quit_returnsOkAndClosesConnection() throws IOException {
+ // Use a dedicated client — QUIT closes the connection
+ try (var qc = new RespClient(port)) {
+ qc.send("QUIT");
+ assertEquals("OK", qc.recvSimple());
+ // Server closes the connection after OK; further reads return EOF
+ }
+ }
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // DATABASE COMMANDS
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class DatabaseTests {
+
+ @Test void dbsize_emptyDb_returnsZero() throws IOException {
+ c.send("DBSIZE");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void dbsize_afterSet_returnsCount() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("DBSIZE");
+ assertEquals(2L, c.recvInt());
+ }
+
+ @Test void flushdb_clearsAllKeys() throws IOException {
+ set("k1", "v1");
+ set("k2", "v2");
+ c.send("FLUSHDB");
+ c.recvOk();
+ c.send("DBSIZE");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void flushall_clearsAllKeys() throws IOException {
+ set("x", "y");
+ c.send("FLUSHALL");
+ c.recvOk();
+ c.send("DBSIZE");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void acl_whoami_returnsDefault() throws IOException {
+ c.send("ACL", "WHOAMI");
+ assertEquals("default", c.recvBulk());
+ }
+
+ @Test void slowlog_get_returnsArray() throws IOException {
+ c.send("SLOWLOG", "GET");
+ Object[] arr = c.recvArray();
+ assertNotNull(arr);
+ assertEquals(0, arr.length);
+ }
+
+ @Test void memory_usage_existingKey_returnsInteger() throws IOException {
+ set("memkey", "hello");
+ c.send("MEMORY", "USAGE", "memkey");
+ Object r = c.recv();
+ // Returns integer (bytes) or null if not supported
+ assertTrue(r == null || r instanceof Long, "MEMORY USAGE should return integer or null");
+ }
+
+ @Test void wait_returnsZero() throws IOException {
+ c.send("WAIT", "0", "0");
+ assertEquals(0L, c.recvInt());
+ }
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // STRING COMMANDS
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class StringTests {
+
+ // ── GET / SET ─────────────────────────────────────────────────────────
+
+ @Test void get_missingKey_returnsNull() throws IOException {
+ c.send("GET", "no-such-key");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void set_andGet_roundtrip() throws IOException {
+ c.send("SET", "foo", "bar");
+ c.recvOk();
+ c.send("GET", "foo");
+ assertEquals("bar", c.recvBulk());
+ }
+
+ @Test void set_overwritesExistingValue() throws IOException {
+ set("k", "original");
+ c.send("SET", "k", "updated");
+ c.recvOk();
+ c.send("GET", "k");
+ assertEquals("updated", c.recvBulk());
+ }
+
+ @Test void set_getFlag_returnsOldValue() throws IOException {
+ set("k", "old");
+ c.send("SET", "k", "new", "GET");
+ assertEquals("old", c.recvBulk());
+ }
+
+ @Test void set_getFlag_missingKey_returnsNull() throws IOException {
+ c.send("SET", "newkey", "v", "GET");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void set_nxFlag_absentKey_setsAndReturnsOk() throws IOException {
+ c.send("SET", "k", "v", "NX");
+ assertEquals("OK", c.recvBulk());
+ }
+
+ @Test void set_nxFlag_presentKey_returnsNull() throws IOException {
+ set("k", "original");
+ c.send("SET", "k", "new", "NX");
+ assertNull(c.recvBulk());
+ c.send("GET", "k");
+ assertEquals("original", c.recvBulk()); // unchanged
+ }
+
+ @Test void set_xxFlag_presentKey_setsAndReturnsOk() throws IOException {
+ set("k", "original");
+ c.send("SET", "k", "updated", "XX");
+ assertEquals("OK", c.recvBulk());
+ }
+
+ @Test void set_xxFlag_absentKey_returnsNull() throws IOException {
+ c.send("SET", "absent", "v", "XX");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void set_withEx_setsTtl() throws IOException {
+ c.send("SET", "k", "v", "EX", "100");
+ c.recvOk();
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 100, "TTL should be in (0, 100], got: " + ttl);
+ }
+
+ @Test void set_withPx_setsTtlMs() throws IOException {
+ c.send("SET", "k", "v", "PX", "100000");
+ c.recvOk();
+ c.send("PTTL", "k");
+ long pttl = c.recvInt();
+ assertTrue(pttl > 0 && pttl <= 100_000, "PTTL should be in (0, 100000], got: " + pttl);
+ }
+
+ @Test void set_withExInvalid_returnsError() throws IOException {
+ c.send("SET", "k", "v", "EX", "0");
+ assertTrue(c.recvError().startsWith("ERR"));
+ }
+
+ @Test void set_keepttl_preservesExistingTtl() throws IOException {
+ c.send("SET", "k", "v1", "EX", "100");
+ c.recvOk();
+ c.send("SET", "k", "v2", "KEEPTTL");
+ c.recvOk();
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0, "KEEPTTL should preserve TTL, got: " + ttl);
+ }
+
+ @Test void set_noTtlOption_clearsPreviousTtl() throws IOException {
+ c.send("SET", "k", "v1", "EX", "100");
+ c.recvOk();
+ c.send("SET", "k", "v2");
+ c.recvOk();
+ c.send("TTL", "k");
+ assertEquals(-1L, c.recvInt()); // no TTL
+ }
+
+ // ── SETNX ─────────────────────────────────────────────────────────────
+
+ @Test void setnx_absentKey_returns1() throws IOException {
+ c.send("SETNX", "k", "v");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void setnx_presentKey_returns0() throws IOException {
+ set("k", "existing");
+ c.send("SETNX", "k", "new");
+ assertEquals(0L, c.recvInt());
+ c.send("GET", "k");
+ assertEquals("existing", c.recvBulk()); // not changed
+ }
+
+ // ── SETEX / PSETEX ────────────────────────────────────────────────────
+
+ @Test void setex_setsValueAndTtl() throws IOException {
+ c.send("SETEX", "k", "60", "hello");
+ c.recvOk();
+ c.send("GET", "k");
+ assertEquals("hello", c.recvBulk());
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 60);
+ }
+
+ @Test void setex_zeroTimeout_returnsError() throws IOException {
+ c.send("SETEX", "k", "0", "v");
+ assertTrue(c.recvError().startsWith("ERR"));
+ }
+
+ @Test void psetex_setsValueAndTtlMs() throws IOException {
+ c.send("PSETEX", "k", "60000", "hello");
+ c.recvOk();
+ c.send("PTTL", "k");
+ long pttl = c.recvInt();
+ assertTrue(pttl > 0 && pttl <= 60_000);
+ }
+
+ // ── GETSET / GETDEL / GETEX ───────────────────────────────────────────
+
+ @Test void getset_returnsOldValue() throws IOException {
+ set("k", "old");
+ c.send("GETSET", "k", "new");
+ assertEquals("old", c.recvBulk());
+ c.send("GET", "k");
+ assertEquals("new", c.recvBulk());
+ }
+
+ @Test void getset_missingKey_returnsNull() throws IOException {
+ c.send("GETSET", "absent", "v");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void getdel_presentKey_returnsAndDeletes() throws IOException {
+ set("k", "hello");
+ c.send("GETDEL", "k");
+ assertEquals("hello", c.recvBulk());
+ c.send("EXISTS", "k");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void getdel_missingKey_returnsNull() throws IOException {
+ c.send("GETDEL", "absent");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void getex_withEx_setsExpiry() throws IOException {
+ set("k", "v");
+ c.send("GETEX", "k", "EX", "30");
+ assertEquals("v", c.recvBulk());
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 30);
+ }
+
+ @Test void getex_withPersist_removesExpiry() throws IOException {
+ c.send("SET", "k", "v", "EX", "30");
+ c.recvOk();
+ c.send("GETEX", "k", "PERSIST");
+ assertEquals("v", c.recvBulk());
+ c.send("TTL", "k");
+ assertEquals(-1L, c.recvInt());
+ }
+
+ // ── MSET / MGET / MSETNX ──────────────────────────────────────────────
+
+ @Test void mset_andMget_roundtrip() throws IOException {
+ c.send("MSET", "a", "1", "b", "2", "c", "3");
+ c.recvOk();
+ c.send("MGET", "a", "b", "c", "missing");
+ Object[] results = c.recvArray();
+ assertNotNull(results);
+ assertEquals(4, results.length);
+ assertEquals("1", results[0]);
+ assertEquals("2", results[1]);
+ assertEquals("3", results[2]);
+ assertNull(results[3]);
+ }
+
+ @Test void msetnx_allAbsent_returns1() throws IOException {
+ c.send("MSETNX", "x", "1", "y", "2");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void msetnx_anyPresent_returns0AndSetsNothing() throws IOException {
+ set("x", "existing");
+ c.send("MSETNX", "x", "new", "y", "2");
+ assertEquals(0L, c.recvInt());
+ c.send("GET", "y");
+ assertNull(c.recvBulk()); // y was NOT set because x was present
+ }
+
+ // ── APPEND / STRLEN ───────────────────────────────────────────────────
+
+ @Test void append_createsKeyAndReturnsLength() throws IOException {
+ c.send("APPEND", "k", "hello");
+ assertEquals(5L, c.recvInt());
+ c.send("GET", "k");
+ assertEquals("hello", c.recvBulk());
+ }
+
+ @Test void append_toExistingKeyExtends() throws IOException {
+ set("k", "hello");
+ c.send("APPEND", "k", " world");
+ assertEquals(11L, c.recvInt());
+ c.send("GET", "k");
+ assertEquals("hello world", c.recvBulk());
+ }
+
+ @Test void strlen_existingKey_returnsLength() throws IOException {
+ set("k", "hello");
+ c.send("STRLEN", "k");
+ assertEquals(5L, c.recvInt());
+ }
+
+ @Test void strlen_missingKey_returnsZero() throws IOException {
+ c.send("STRLEN", "absent");
+ assertEquals(0L, c.recvInt());
+ }
+
+ // ── INCR / INCRBY / INCRBYFLOAT / DECR / DECRBY ──────────────────────
+
+ @Test void incr_absentKey_createsAndReturns1() throws IOException {
+ c.send("INCR", "counter");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void incr_existingKey_increments() throws IOException {
+ set("counter", "10");
+ c.send("INCR", "counter");
+ assertEquals(11L, c.recvInt());
+ }
+
+ @Test void incr_nonParseableValue_treatsAsZero() throws IOException {
+ // RiptideKV uses unwrap_or(0) for un-parseable values: INCR treats them as 0
+ set("incr_bad_type", "notanumber");
+ c.send("INCR", "incr_bad_type");
+ assertEquals(1L, c.recvInt(), "INCR on non-numeric value should treat it as 0 and return 1");
+ }
+
+ @Test void incrby_incrementsByAmount() throws IOException {
+ set("k", "10");
+ c.send("INCRBY", "k", "5");
+ assertEquals(15L, c.recvInt());
+ }
+
+ @Test void incrbyfloat_addsFraction() throws IOException {
+ set("k", "10");
+ c.send("INCRBYFLOAT", "k", "1.5");
+ String result = c.recvBulk();
+ assertNotNull(result);
+ assertEquals(11.5, Double.parseDouble(result), 0.001);
+ }
+
+ @Test void decr_decrementsBy1() throws IOException {
+ set("k", "10");
+ c.send("DECR", "k");
+ assertEquals(9L, c.recvInt());
+ }
+
+ @Test void decrby_decrementsByAmount() throws IOException {
+ set("k", "10");
+ c.send("DECRBY", "k", "3");
+ assertEquals(7L, c.recvInt());
+ }
+
+ // ── GETRANGE / SETRANGE ───────────────────────────────────────────────
+
+ @Test void getrange_returnsSubstring() throws IOException {
+ set("k", "hello world");
+ c.send("GETRANGE", "k", "0", "4");
+ assertEquals("hello", c.recvBulk());
+ }
+
+ @Test void getrange_negativeIndex_fromEnd() throws IOException {
+ set("k", "hello world");
+ c.send("GETRANGE", "k", "6", "-1");
+ assertEquals("world", c.recvBulk());
+ }
+
+ @Test void setrange_overwritesPortionAndReturnsLength() throws IOException {
+ set("k", "hello world");
+ c.send("SETRANGE", "k", "6", "Redis");
+ assertEquals(11L, c.recvInt());
+ c.send("GET", "k");
+ assertEquals("hello Redis", c.recvBulk());
+ }
+
+ // ── Large value ───────────────────────────────────────────────────────
+
+ @Test void set_largeValue_roundtrip() throws IOException {
+ String large = "x".repeat(512 * 1024); // 512 KiB
+ c.send("SET", "bigkey", large);
+ c.recvOk();
+ c.send("GET", "bigkey");
+ assertEquals(large, c.recvBulk());
+ }
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // KEY COMMANDS
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class KeyTests {
+
+ // ── DEL / UNLINK ──────────────────────────────────────────────────────
+
+ @Test void del_presentKey_returns1() throws IOException {
+ set("k", "v");
+ c.send("DEL", "k");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void del_missingKey_returns0() throws IOException {
+ c.send("DEL", "absent");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void del_multipleKeys_returnsDeletedCount() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("DEL", "a", "b", "missing");
+ assertEquals(2L, c.recvInt());
+ }
+
+ @Test void unlink_presentKey_returns1() throws IOException {
+ set("k", "v");
+ c.send("UNLINK", "k");
+ assertEquals(1L, c.recvInt());
+ c.send("EXISTS", "k");
+ assertEquals(0L, c.recvInt());
+ }
+
+ // ── EXISTS ────────────────────────────────────────────────────────────
+
+ @Test void exists_presentKey_returns1() throws IOException {
+ set("k", "v");
+ c.send("EXISTS", "k");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void exists_absentKey_returns0() throws IOException {
+ c.send("EXISTS", "absent");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void exists_multipleKeys_returnsCount() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("EXISTS", "a", "b", "missing");
+ assertEquals(2L, c.recvInt());
+ }
+
+ // ── TYPE ──────────────────────────────────────────────────────────────
+
+ @Test void type_stringKey_returnsString() throws IOException {
+ set("k", "v");
+ c.send("TYPE", "k");
+ assertEquals("string", c.recvSimple());
+ }
+
+ @Test void type_absentKey_returnsNone() throws IOException {
+ c.send("TYPE", "absent");
+ assertEquals("none", c.recvSimple());
+ }
+
+ // ── RENAME / RENAMENX ─────────────────────────────────────────────────
+
+ @Test void rename_movesValue() throws IOException {
+ set("src", "hello");
+ c.send("RENAME", "src", "dst");
+ c.recvOk();
+ c.send("GET", "dst");
+ assertEquals("hello", c.recvBulk());
+ c.send("EXISTS", "src");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void rename_missingSource_returnsError() throws IOException {
+ c.send("RENAME", "absent", "dst");
+ assertTrue(c.recvError().startsWith("ERR"));
+ }
+
+ @Test void rename_preservesTtl() throws IOException {
+ c.send("SET", "src", "v", "EX", "100");
+ c.recvOk();
+ c.send("RENAME", "src", "dst");
+ c.recvOk();
+ c.send("TTL", "dst");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 100, "Renamed key should preserve TTL, got: " + ttl);
+ }
+
+ @Test void renamenx_absentDest_returns1() throws IOException {
+ set("src", "hello");
+ c.send("RENAMENX", "src", "dst");
+ assertEquals(1L, c.recvInt());
+ }
+
+ @Test void renamenx_presentDest_returns0() throws IOException {
+ set("src", "hello");
+ set("dst", "existing");
+ c.send("RENAMENX", "src", "dst");
+ assertEquals(0L, c.recvInt());
+ c.send("GET", "dst");
+ assertEquals("existing", c.recvBulk()); // not overwritten
+ }
+
+ // ── RANDOMKEY / TOUCH ─────────────────────────────────────────────────
+
+ @Test void randomkey_noKeys_returnsNull() throws IOException {
+ c.send("RANDOMKEY");
+ assertNull(c.recvBulk());
+ }
+
+ @Test void randomkey_withKeys_returnsAKey() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("RANDOMKEY");
+ String key = c.recvBulk();
+ assertNotNull(key);
+ assertTrue(key.equals("a") || key.equals("b"), "Unexpected key: " + key);
+ }
+
+ @Test void touch_existingKeys_returnsCount() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("TOUCH", "a", "b", "missing");
+ assertEquals(2L, c.recvInt());
+ }
+
+ // ── EXPIRE / PEXPIRE / EXPIREAT / PEXPIREAT ───────────────────────────
+
+ @Test void expire_setsTtlInSeconds() throws IOException {
+ set("k", "v");
+ c.send("EXPIRE", "k", "60");
+ assertEquals(1L, c.recvInt());
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 60, "TTL should be in (0, 60], got: " + ttl);
+ }
+
+ @Test void expire_absentKey_returns0() throws IOException {
+ c.send("EXPIRE", "absent", "60");
+ assertEquals(0L, c.recvInt());
+ }
+
+ @Test void pexpire_setsTtlInMs() throws IOException {
+ set("k", "v");
+ c.send("PEXPIRE", "k", "60000");
+ assertEquals(1L, c.recvInt());
+ c.send("PTTL", "k");
+ long pttl = c.recvInt();
+ assertTrue(pttl > 0 && pttl <= 60_000, "PTTL should be in (0, 60000], got: " + pttl);
+ }
+
+ @Test void expireat_setsUnixTimestamp() throws IOException {
+ set("k", "v");
+ long future = Instant.now().getEpochSecond() + 120;
+ c.send("EXPIREAT", "k", String.valueOf(future));
+ assertEquals(1L, c.recvInt());
+ c.send("TTL", "k");
+ long ttl = c.recvInt();
+ assertTrue(ttl > 0 && ttl <= 120, "TTL should be in (0, 120], got: " + ttl);
+ }
+
+ @Test void pexpireat_setsUnixMs() throws IOException {
+ set("k", "v");
+ long futureMs = Instant.now().toEpochMilli() + 120_000;
+ c.send("PEXPIREAT", "k", String.valueOf(futureMs));
+ assertEquals(1L, c.recvInt());
+ c.send("PTTL", "k");
+ long pttl = c.recvInt();
+ assertTrue(pttl > 0 && pttl <= 120_000, "PTTL out of range: " + pttl);
+ }
+
+ // ── TTL / PTTL ────────────────────────────────────────────────────────
+
+ @Test void ttl_noExpiry_returnsMinusOne() throws IOException {
+ set("k", "v");
+ c.send("TTL", "k");
+ assertEquals(-1L, c.recvInt());
+ }
+
+ @Test void ttl_absentKey_returnsMinusTwo() throws IOException {
+ c.send("TTL", "absent");
+ assertEquals(-2L, c.recvInt());
+ }
+
+ @Test void pttl_absentKey_returnsMinusTwo() throws IOException {
+ c.send("PTTL", "absent");
+ assertEquals(-2L, c.recvInt());
+ }
+
+ @Test void pttl_noExpiry_returnsMinusOne() throws IOException {
+ set("k", "v");
+ c.send("PTTL", "k");
+ assertEquals(-1L, c.recvInt());
+ }
+
+ // ── PERSIST ───────────────────────────────────────────────────────────
+
+ @Test void persist_removesExpiry() throws IOException {
+ c.send("SET", "k", "v", "EX", "60");
+ c.recvOk();
+ c.send("PERSIST", "k");
+ assertEquals(1L, c.recvInt());
+ c.send("TTL", "k");
+ assertEquals(-1L, c.recvInt()); // no longer expires
+ }
+
+ @Test void persist_noExpiry_returns0() throws IOException {
+ set("k", "v");
+ c.send("PERSIST", "k");
+ assertEquals(0L, c.recvInt());
+ }
+
+ // ── EXPIRETIME / PEXPIRETIME ──────────────────────────────────────────
+
+ @Test void expiretime_keyWithTtl_returnsUnixTimestamp() throws IOException {
+ long future = Instant.now().getEpochSecond() + 120;
+ set("k", "v"); // create the key first
+ c.send("EXPIREAT", "k", String.valueOf(future));
+ assertEquals(1L, c.recvInt()); // must read the EXPIREAT reply
+ c.send("EXPIRETIME", "k");
+ long et = c.recvInt();
+ assertTrue(et > 0 && et <= future + 1, "EXPIRETIME out of range: " + et);
+ }
+
+ @Test void expiretime_noExpiry_returnsMinusOne() throws IOException {
+ set("k", "v");
+ c.send("EXPIRETIME", "k");
+ assertEquals(-1L, c.recvInt());
+ }
+
+ @Test void expiretime_absentKey_returnsMinusTwo() throws IOException {
+ c.send("EXPIRETIME", "absent");
+ assertEquals(-2L, c.recvInt());
+ }
+
+ @Test void pexpiretime_absentKey_returnsMinusTwo() throws IOException {
+ c.send("PEXPIRETIME", "absent");
+ assertEquals(-2L, c.recvInt());
+ }
+
+ // ── KEYS / SCAN ───────────────────────────────────────────────────────
+
+ @Test void keys_wildcardPattern_returnsMatchingKeys() throws IOException {
+ c.send("MSET", "user:1", "a", "user:2", "b", "item:1", "c");
+ c.recvOk();
+ c.send("KEYS", "user:*");
+ Object[] keys = c.recvArray();
+ assertNotNull(keys);
+ assertEquals(2, keys.length);
+ List keyList = Arrays.stream(keys).map(Object::toString).collect(Collectors.toList());
+ assertTrue(keyList.stream().allMatch(k -> k.startsWith("user:")));
+ }
+
+ @Test void keys_questionMarkPattern_matchesSingleChar() throws IOException {
+ c.send("MSET", "foo", "1", "bar", "2", "baz", "3", "foobar", "4");
+ c.recvOk();
+ c.send("KEYS", "???");
+ Object[] keys = c.recvArray();
+ assertNotNull(keys);
+ assertEquals(3, keys.length); // foo, bar, baz
+ }
+
+ @Test void keys_starPattern_returnsAllKeys() throws IOException {
+ set("a", "1");
+ set("b", "2");
+ c.send("KEYS", "*");
+ Object[] keys = c.recvArray();
+ assertNotNull(keys);
+ assertEquals(2, keys.length);
+ }
+
+ @Test void scan_basicCursor_returnsKeysArray() throws IOException {
+ set("k1", "v1");
+ set("k2", "v2");
+ c.send("SCAN", "0");
+ Object[] result = c.recvArray();
+ assertNotNull(result);
+ assertEquals(2, result.length);
+ // result[0] = next cursor, result[1] = keys array
+ Object[] scanKeys = (Object[]) result[1];
+ assertNotNull(scanKeys);
+ }
+
+ @Test void scan_withMatchPattern_filtersKeys() throws IOException {
+ c.send("MSET", "prefix:1", "a", "prefix:2", "b", "other", "c");
+ c.recvOk();
+ c.send("SCAN", "0", "MATCH", "prefix:*");
+ Object[] result = c.recvArray();
+ Object[] keys = (Object[]) result[1];
+ assertNotNull(keys);
+ for (Object k : keys) {
+ assertTrue(k.toString().startsWith("prefix:"),
+ "SCAN MATCH returned unexpected key: " + k);
+ }
+ }
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // REAL-TIME EXPIRY TESTS (use Thread.sleep — kept minimal)
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class ExpiryTests {
+
+ @Test void key_expiresAndBecomesInvisible() throws Exception {
+ c.send("SET", "ex", "v", "PX", "300"); // 300 ms TTL
+ c.recvOk();
+ Thread.sleep(400);
+ c.send("GET", "ex");
+ assertNull(c.recvBulk(), "Key should be gone after TTL expires");
+ }
+
+ @Test void del_afterExpiry_returns0() throws Exception {
+ c.send("SET", "ex", "v", "PX", "300");
+ c.recvOk();
+ Thread.sleep(400);
+ c.send("DEL", "ex");
+ assertEquals(0L, c.recvInt(), "DEL on expired key should return 0");
+ }
+
+ @Test void exists_afterExpiry_returns0() throws Exception {
+ c.send("SET", "ex", "v", "PX", "300");
+ c.recvOk();
+ Thread.sleep(400);
+ c.send("EXISTS", "ex");
+ assertEquals(0L, c.recvInt(), "EXISTS on expired key should return 0");
+ }
+
+ @Test void ttl_afterExpiry_returnsMinusTwo() throws Exception {
+ c.send("SET", "ex", "v", "PX", "300");
+ c.recvOk();
+ Thread.sleep(400);
+ c.send("TTL", "ex");
+ assertEquals(-2L, c.recvInt(), "TTL on expired key should return -2");
+ }
+
+ @Test void dbsize_afterExpiry_decrements() throws Exception {
+ c.send("SET", "ex", "v", "PX", "300");
+ c.recvOk();
+ set("perm", "v");
+ c.send("DBSIZE");
+ assertEquals(2L, c.recvInt());
+ Thread.sleep(400);
+ // Access the expired key to trigger eviction
+ c.send("GET", "ex");
+ c.recvBulk();
+ c.send("DBSIZE");
+ assertEquals(1L, c.recvInt());
+ }
+ }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // EDGE CASES
+ // ═════════════════════════════════════════════════════════════════════════
+
+ @Nested
+ class EdgeCaseTests {
+
+ @Test void unknownCommand_returnsError() throws IOException {
+ c.send("NOTACOMMAND");
+ assertTrue(c.recvError().startsWith("ERR"));
+ }
+
+ @Test void pipelining_sendMultipleBeforeReading() throws IOException {
+ // Pipeline 5 SETs without reading responses
+ for (int i = 0; i < 5; i++) {
+ c.send("SET", "pk" + i, "pv" + i);
+ }
+ // Now read all 5 OKs
+ for (int i = 0; i < 5; i++) {
+ c.recvOk();
+ }
+ // Verify with MGET
+ c.send("MGET", "pk0", "pk1", "pk2", "pk3", "pk4");
+ Object[] vals = c.recvArray();
+ assertNotNull(vals);
+ assertEquals(5, vals.length);
+ for (int i = 0; i < 5; i++) {
+ assertEquals("pv" + i, vals[i], "Unexpected value at index " + i);
+ }
+ }
+
+ @Test void concurrentClients_doNotInterfereSets() throws Exception {
+ int threads = 10;
+ int opsEach = 20;
+ var executor = Executors.newFixedThreadPool(threads);
+ var errors = new CopyOnWriteArrayList();
+
+ List> futures = new ArrayList<>();
+ for (int t = 0; t < threads; t++) {
+ final int tid = t;
+ futures.add(executor.submit(() -> {
+ try (var tc = new RespClient(port)) {
+ for (int i = 0; i < opsEach; i++) {
+ String key = "thread" + tid + ":key" + i;
+ tc.send("SET", key, "val" + i);
+ tc.recvOk();
+ tc.send("GET", key);
+ String got = tc.recvBulk();
+ if (!("val" + i).equals(got)) {
+ errors.add("thread" + tid + " key=" + key + " expected val" + i + " got " + got);
+ }
+ }
+ } catch (Exception e) {
+ errors.add("thread" + tid + " threw: " + e.getMessage());
+ }
+ return null;
+ }));
+ }
+
+ for (var f : futures) f.get(10, TimeUnit.SECONDS);
+ executor.shutdown();
+ assertTrue(errors.isEmpty(), "Concurrent errors: " + errors);
+ }
+
+ @Test void concurrentIncr_isSerializedByLock() throws Exception {
+ set("counter", "0");
+ int threads = 10;
+ int incrEach = 50;
+ var executor = Executors.newFixedThreadPool(threads);
+ List> futures = new ArrayList<>();
+
+ for (int t = 0; t < threads; t++) {
+ futures.add(executor.submit(() -> {
+ try (var tc = new RespClient(port)) {
+ for (int i = 0; i < incrEach; i++) {
+ tc.send("INCR", "counter");
+ tc.recvInt();
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return null;
+ }));
+ }
+
+ for (var f : futures) f.get(10, TimeUnit.SECONDS);
+ executor.shutdown();
+
+ c.send("GET", "counter");
+ String val = c.recvBulk();
+ assertEquals(threads * incrEach, Integer.parseInt(val),
+ "Concurrent INCRs should be serialized; expected " + (threads * incrEach) + " got " + val);
+ }
+
+ @Test void binarySafeValue_roundtrip() throws Exception {
+ // Write a raw RESP command containing binary bytes (0x00 0x01 0x02) via raw socket
+ byte[] cmd = "*3\r\n$3\r\nSET\r\n$6\r\nbinkey\r\n$3\r\n\u0000\u0001\u0002\r\n"
+ .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
+ c.send("SET", "binkey", "\u0000\u0001\u0002");
+ c.recvOk();
+ c.send("STRLEN", "binkey");
+ assertEquals(3L, c.recvInt());
+ }
+
+ @Test void multipleConsecutivePings_allReturnPong() throws IOException {
+ for (int i = 0; i < 10; i++) {
+ c.send("PING");
+ assertEquals("PONG", c.recvSimple(), "PING #" + i + " failed");
+ }
+ }
+
+ @Test void info_keyspace_reflectsActualKeyCount() throws IOException {
+ c.send("MSET", "x", "1", "y", "2", "z", "3");
+ c.recvOk();
+ c.send("INFO", "keyspace");
+ String info = c.recvBulk();
+ assertNotNull(info);
+ assertTrue(info.contains("keys=3"),
+ "INFO keyspace should report keys=3 but got: " + info);
+ }
+ }
+}
diff --git a/java/src/test/java/io/riptidekv/RiptideKVConfigTest.java b/java/src/test/java/io/riptidekv/RiptideKVConfigTest.java
new file mode 100644
index 0000000..e53c55d
--- /dev/null
+++ b/java/src/test/java/io/riptidekv/RiptideKVConfigTest.java
@@ -0,0 +1,155 @@
+package io.riptidekv;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link RiptideKVConfig} and its builder.
+ * No server process is started — purely tests the Java config object.
+ */
+class RiptideKVConfigTest {
+
+ // ── Default values ────────────────────────────────────────────────────────
+
+ @Test
+ void defaults_bindIsLocalhost6379() {
+ var cfg = RiptideKVConfig.builder().build();
+ assertEquals("127.0.0.1:6379", cfg.getBind());
+ }
+
+ @Test
+ void defaults_flushKbIs1024() {
+ var cfg = RiptideKVConfig.builder().build();
+ assertEquals(1024, cfg.getFlushKb());
+ }
+
+ @Test
+ void defaults_walSyncIsTrue() {
+ var cfg = RiptideKVConfig.builder().build();
+ assertTrue(cfg.isWalSync());
+ }
+
+ @Test
+ void defaults_dataDirIsUnderTmpdir() {
+ var cfg = RiptideKVConfig.builder().build();
+ assertTrue(cfg.getDataDir().toString().contains("riptidekv"),
+ "default dataDir should contain 'riptidekv', got: " + cfg.getDataDir());
+ }
+
+ // ── Port extraction ───────────────────────────────────────────────────────
+
+ @Test
+ void getPort_extractsFromDefaultBind() {
+ assertEquals(6379, RiptideKVConfig.builder().build().getPort());
+ }
+
+ @Test
+ void getPort_extractsCustomPort() {
+ var cfg = RiptideKVConfig.builder().bind("127.0.0.1:6380").build();
+ assertEquals(6380, cfg.getPort());
+ }
+
+ @Test
+ void getPort_worksWithAllInterfaces() {
+ var cfg = RiptideKVConfig.builder().bind("0.0.0.0:9999").build();
+ assertEquals(9999, cfg.getPort());
+ }
+
+ // ── Custom values ─────────────────────────────────────────────────────────
+
+ @Test
+ void customBind_isStored() {
+ var cfg = RiptideKVConfig.builder().bind("0.0.0.0:7777").build();
+ assertEquals("0.0.0.0:7777", cfg.getBind());
+ }
+
+ @Test
+ void customDataDir_isStored() {
+ var dir = Paths.get("/tmp/mydb");
+ var cfg = RiptideKVConfig.builder().dataDir(dir).build();
+ assertEquals(dir, cfg.getDataDir());
+ }
+
+ @Test
+ void customFlushKb_isStored() {
+ var cfg = RiptideKVConfig.builder().flushKb(4096).build();
+ assertEquals(4096, cfg.getFlushKb());
+ }
+
+ @Test
+ void walSyncFalse_isStored() {
+ var cfg = RiptideKVConfig.builder().walSync(false).build();
+ assertFalse(cfg.isWalSync());
+ }
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ @Test
+ void flushKbZero_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().flushKb(0).build());
+ }
+
+ @Test
+ void flushKbNegative_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().flushKb(-1).build());
+ }
+
+ @Test
+ void nullDataDir_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().dataDir(null).build());
+ }
+
+ @Test
+ void blankBind_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().bind("").build());
+ }
+
+ @Test
+ void nullBind_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().bind(null).build());
+ }
+
+ // ── Builder fluency ───────────────────────────────────────────────────────
+
+ @Test
+ void invalidBindNoColon_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().bind("localhost").build());
+ }
+
+ @Test
+ void invalidBindNonNumericPort_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().bind("127.0.0.1:abc").build());
+ }
+
+ @Test
+ void invalidBindPortOutOfRange_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RiptideKVConfig.builder().bind("127.0.0.1:99999").build());
+ }
+
+ @Test
+ void builder_isFullyFluent() {
+ var cfg = RiptideKVConfig.builder()
+ .bind("127.0.0.1:16379")
+ .dataDir(Paths.get("/tmp/test"))
+ .flushKb(512)
+ .walSync(false)
+ .build();
+
+ assertEquals("127.0.0.1:16379", cfg.getBind());
+ assertEquals(Paths.get("/tmp/test"), cfg.getDataDir());
+ assertEquals(512, cfg.getFlushKb());
+ assertFalse(cfg.isWalSync());
+ assertEquals(16379, cfg.getPort());
+ }
+}
diff --git a/java/src/test/java/io/riptidekv/RiptideKVServerTest.java b/java/src/test/java/io/riptidekv/RiptideKVServerTest.java
new file mode 100644
index 0000000..365e3d3
--- /dev/null
+++ b/java/src/test/java/io/riptidekv/RiptideKVServerTest.java
@@ -0,0 +1,179 @@
+package io.riptidekv;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for {@link RiptideKVServer} lifecycle: start, stop, isRunning, error cases.
+ * Each test starts and stops its own server for full isolation.
+ */
+class RiptideKVServerTest {
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ static int freePort() throws IOException {
+ try (var ss = new ServerSocket(0)) { return ss.getLocalPort(); }
+ }
+
+ static RiptideKVServer startServer(int port, Path dataDir) throws IOException {
+ var cfg = RiptideKVConfig.builder()
+ .bind("127.0.0.1:" + port)
+ .dataDir(dataDir)
+ .walSync(false)
+ .build();
+ var server = new RiptideKVServer(cfg);
+ server.start();
+ return server;
+ }
+
+ static boolean canConnect(int port) {
+ try (var ignored = new Socket("127.0.0.1", port)) {
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ // ── Start ─────────────────────────────────────────────────────────────────
+
+ @Test
+ void start_serverAcceptsConnections(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp)) {
+ assertTrue(server.isRunning());
+ assertTrue(canConnect(port), "Should be able to connect after start()");
+ }
+ }
+
+ @Test
+ void start_respondsToRespPing(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp);
+ var c = new RespClient(port)) {
+ c.send("PING");
+ assertEquals("PONG", c.recvSimple());
+ }
+ }
+
+ @Test
+ void start_createsDataDirectoryIfAbsent(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ Path nested = tmp.resolve("a").resolve("b").resolve("c");
+ // nested does NOT exist yet
+ assertFalse(nested.toFile().exists());
+ try (var ignored = startServer(port, nested)) {
+ assertTrue(nested.toFile().exists(), "start() must create the data directory");
+ }
+ }
+
+ @Test
+ void start_createsSstSubdirectory(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var ignored = startServer(port, tmp)) {
+ assertTrue(tmp.resolve("sst").toFile().isDirectory(),
+ "start() must create sst/ subdirectory");
+ }
+ }
+
+ // ── isRunning ─────────────────────────────────────────────────────────────
+
+ @Test
+ void isRunning_falseBeforeStart(@TempDir Path tmp) {
+ var cfg = RiptideKVConfig.builder()
+ .dataDir(tmp).walSync(false).build();
+ var server = new RiptideKVServer(cfg);
+ assertFalse(server.isRunning());
+ }
+
+ @Test
+ void isRunning_trueAfterStart(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp)) {
+ assertTrue(server.isRunning());
+ }
+ }
+
+ @Test
+ void isRunning_falseAfterClose(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ var server = startServer(port, tmp);
+ assertTrue(server.isRunning());
+ server.close();
+ assertFalse(server.isRunning());
+ }
+
+ // ── Stop / close ──────────────────────────────────────────────────────────
+
+ @Test
+ void close_releasesPort(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ var server = startServer(port, tmp);
+ assertTrue(canConnect(port));
+ server.close();
+ // Give the OS a moment to release the port
+ Thread.sleep(200);
+ assertFalse(canConnect(port), "Port should be released after close()");
+ }
+
+ @Test
+ void close_isIdempotent(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ var server = startServer(port, tmp);
+ server.close();
+ assertDoesNotThrow(server::close, "Second close() should not throw");
+ }
+
+ @Test
+ void tryWithResources_closesAutomatically(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var ignored = startServer(port, tmp)) {
+ assertTrue(canConnect(port));
+ }
+ Thread.sleep(200);
+ assertFalse(canConnect(port), "Server should stop at end of try-with-resources");
+ }
+
+ // ── Double start ──────────────────────────────────────────────────────────
+
+ @Test
+ void start_whenAlreadyRunning_throwsIllegalState(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp)) {
+ assertThrows(IllegalStateException.class, server::start,
+ "start() on a running server should throw IllegalStateException");
+ }
+ }
+
+ // ── getPort / getBind ─────────────────────────────────────────────────────
+
+ @Test
+ void getPort_matchesConfig(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp)) {
+ assertEquals(port, server.getPort());
+ }
+ }
+
+ @Test
+ void getBind_matchesConfig(@TempDir Path tmp) throws Exception {
+ int port = freePort();
+ try (var server = startServer(port, tmp)) {
+ assertEquals("127.0.0.1:" + port, server.getBind());
+ }
+ }
+
+ // ── Null config guard ─────────────────────────────────────────────────────
+
+ @Test
+ void constructor_nullConfig_throwsIllegalArgument() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new RiptideKVServer(null));
+ }
+}