code
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import redis.clients.jedis.exceptions.JedisConnectionException
import java.net.URI
import kotlin.test.Test
/**
* RedisClient wrapper test suite
*
* Test focus:
* - Client creation and configuration
* - Connection pool management
* - Transaction encapsulation
* - Pipeline encapsulation
* - Exception handling
* - Resource cleanup
* - Edge cases and boundary conditions
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RedisClientTest {
private lateinit var client: RedisClient
private val testHost = "localhost"
private val testPort = 6379
@BeforeAll
fun setup() {
client = RedisClient.create(
host = testHost,
port = testPort,
maxTotal = 10,
maxIdle = 5,
minIdle = 2,
maxWaitMillis = 3000
)
}
@AfterAll
fun teardown() {
client.close()
}
@BeforeEach
fun cleanupBefore() {
// Clean up test data
client.execute { it.flushDB() }
}
// ========== Client Creation Tests ==========
@Test
fun `should create client with default parameters`() {
RedisClient.create().use { testClient ->
assertNotNull(testClient)
assertFalse(testClient.isClosed())
}
}
@Test
fun `should create client with custom parameters`() {
RedisClient.create(
host = testHost,
port = testPort,
database = 0,
timeout = 5000,
maxWaitMillis = 5000
).use { testClient ->
assertNotNull(testClient)
// Verify connection is usable
testClient.set("test_key", "test_value")
assertEquals("test_value", testClient.get("test_key"))
}
}
@Test
fun `should create client from URI string`() {
val uri = "redis://$testHost:$testPort"
RedisClient.create(uri).use { testClient ->
assertNotNull(testClient)
assertFalse(testClient.isClosed())
}
}
@Test
fun `should create client from URI instance`() {
val uri = URI("redis://$testHost:$testPort")
RedisClient.create(uri).use { testClient ->
assertNotNull(testClient)
assertFalse(testClient.isClosed())
}
}
@Test
fun `should create client with authentication`() {
// Note: This test assumes Redis is configured with password "testpass"
// Skip if Redis doesn't require authentication
try {
RedisClient.create(
host = testHost,
port = testPort,
password = "testpass"
).use { testClient ->
assertNotNull(testClient)
}
} catch (e: Exception) {
// Skip test if authentication is not configured
println("Skipping auth test - Redis authentication not configured")
}
}
@Test
fun `should create client with different database`() {
RedisClient.create(
host = testHost,
port = testPort,
database = 1
).use { testClient ->
testClient.set("db_test", "value")
assertEquals("value", testClient.get("db_test"))
// Verify it's isolated from database 0
assertNull(client.get("db_test"))
}
}
@Test
fun `should create client with custom pool configuration`() {
RedisClient.create(
host = testHost,
port = testPort,
maxTotal = 20,
maxIdle = 10,
minIdle = 5,
maxWaitMillis = 5000
).use { testClient ->
assertNotNull(testClient)
// Verify pool works with custom settings
repeat(15) { i ->
testClient.set("pool_$i", "value_$i")
}
}
}
// ========== Connection Pool Management Tests ==========
@Test
fun `should properly manage connection lifecycle`() {
val testClient = RedisClient.create(
host = testHost,
port = testPort
)
assertFalse(testClient.isClosed())
// Execute operations
testClient.set("key", "value")
assertEquals("value", testClient.get("key"))
// Close client
testClient.close()
assertTrue(testClient.isClosed())
}
@Test
fun `should handle multiple concurrent operations`() {
val threads = List(10) { threadIndex ->
Thread {
repeat(100) { i ->
val key = "concurrent_key_${threadIndex}_$i"
val value = "value_${threadIndex}_$i"
client.set(key, value)
assertEquals(value, client.get(key))
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Verify pool is still healthy
assertFalse(client.isClosed())
}
@Test
fun `should reuse connections from pool`() {
// Execute multiple operations to verify connection reuse
repeat(100) { i ->
client.set("pool_test_$i", "value_$i")
assertEquals("value_$i", client.get("pool_test_$i"))
}
assertFalse(client.isClosed())
}
@Test
fun `should handle pool exhaustion gracefully`() {
val smallPoolClient = RedisClient.create(
host = testHost,
port = testPort,
maxTotal = 2,
maxWaitMillis = 1000
)
smallPoolClient.use { testClient ->
// Should handle limited pool size without errors
val threads = List(5) {
Thread {
repeat(10) { i ->
testClient.set("exhaustion_$i", "value_$i")
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
}
}
@Test
fun `should return connections to pool after use`() {
// Execute operation and verify connection is returned
client.set("test", "value")
// Execute another operation - should reuse connection from pool
client.get("test")
// Pool should still be healthy
assertFalse(client.isClosed())
}
// ========== execute Method Tests ==========
@Test
fun `should execute custom commands through execute method`() {
val result = client.execute { jedis ->
jedis.set("custom_key", "custom_value")
jedis.get("custom_key")
}
assertEquals("custom_value", result)
}
@Test
fun `should handle exceptions in execute block`() {
assertThrows<Exception> {
client.execute { jedis ->
jedis.set("key", "value")
throw RuntimeException("Test exception")
}
}
}
@Test
fun `should support nested execute calls`() {
val result = client.execute { outer ->
outer.set("outer_key", "outer_value")
client.execute { inner ->
inner.set("inner_key", "inner_value")
inner.get("outer_key")
}
}
assertEquals("outer_value", result)
assertEquals("inner_value", client.get("inner_key"))
}
@Test
fun `should return correct types from execute`() {
val stringResult: String? = client.execute { it.get("non_existent") }
assertNull(stringResult)
val longResult: Long = client.execute { it.incr("counter") }
assertEquals(1L, longResult)
val booleanResult: Boolean = client.execute { it.exists("counter") }
assertTrue(booleanResult)
}
@Test
fun `should handle null returns in execute`() {
val result = client.execute { it.get("non_existent_key") }
assertNull(result)
}
// ========== Transaction Tests ==========
@Test
fun `should execute commands atomically in transaction`() {
val results = client.transaction { tx ->
tx.set("tx_key1", "value1")
tx.set("tx_key2", "value2")
tx.incr("tx_counter")
}
assertNotNull(results)
assertEquals("value1", client.get("tx_key1"))
assertEquals("value2", client.get("tx_key2"))
assertEquals(1L, client.get("tx_counter")?.toLong())
}
@Test
fun `should rollback transaction on exception`() {
// Set initial value
client.set("rollback_key", "initial")
assertThrows<RuntimeException> {
client.transaction { tx ->
tx.set("rollback_key", "updated")
tx.set("new_key", "new_value")
throw RuntimeException("Force rollback")
}
}
// Verify transaction was rolled back
assertEquals("initial", client.get("rollback_key"))
assertNull(client.get("new_key"))
}
@Test
fun `should handle empty transaction`() {
val results = client.transaction { }
assertEquals(emptyList<Any>(), results)
}
@Test
fun `should support multiple operations in single transaction`() {
client.set("counter", "0")
val results = client.transaction { tx ->
repeat(10) {
tx.incr("counter")
}
}
assertEquals(10, results.size)
assertEquals(10L, client.get("counter")?.toLong())
}
@Test
fun `should handle transaction with conditional operations`() {
client.set("watch_key", "initial")
val results = client.transaction { tx ->
tx.set("watch_key", "updated")
tx.get("watch_key")
}
assertNotNull(results)
assertEquals("updated", client.get("watch_key"))
}
@Test
fun `should handle nested transaction calls`() {
// Our wrapper allows nested transaction calls as each uses independent connection
// This is different from Redis's native MULTI behavior which doesn't support nesting
val results = client.transaction { tx1 ->
tx1.set("outer", "value1")
// Inner transaction uses a separate connection from the pool
client.transaction { tx2 ->
tx2.set("inner", "value2")
}
}
// Both transactions should complete independently
assertNotNull(results)
assertEquals("value1", client.get("outer"))
assertEquals("value2", client.get("inner"))
}
@Test
fun `should return empty list when transaction fails to exec`() {
// Test edge case where exec returns null
val results = client.transaction { tx ->
tx.set("key", "value")
// Transaction should still complete normally
}
assertNotNull(results)
}
// ========== Pipeline Tests ==========
@Test
fun `should execute commands in pipeline`() {
client.pipeline { pipe ->
repeat(100) { i ->
pipe.set("pipe_key_$i", "value_$i")
}
}
// Verify all commands were executed
repeat(100) { i ->
assertEquals("value_$i", client.get("pipe_key_$i"))
}
}
@Test
fun `should improve performance with pipeline`() {
val normalStart = System.currentTimeMillis()
repeat(1000) { i ->
client.set("normal_$i", "value_$i")
}
val normalTime = System.currentTimeMillis() - normalStart
// Cleanup
client.execute { it.flushDB() }
val pipelineStart = System.currentTimeMillis()
client.pipeline { pipe ->
repeat(1000) { i ->
pipe.set("pipeline_$i", "value_$i")
}
}
val pipelineTime = System.currentTimeMillis() - pipelineStart
// Pipeline should be faster (typically 5-10x)
println("Normal: ${normalTime}ms, Pipeline: ${pipelineTime}ms")
assertTrue(pipelineTime < normalTime,
"Pipeline ($pipelineTime ms) should be faster than normal ($normalTime ms)")
}
@Test
fun `should handle exception in pipeline`() {
assertThrows<RuntimeException> {
client.pipeline { pipe ->
pipe.set("key1", "value1")
throw RuntimeException("Pipeline error")
}
}
}
@Test
fun `should support mixed operations in pipeline`() {
client.pipeline { pipe ->
pipe.set("string_key", "string_value")
pipe.lpush("list_key", "item1", "item2")
pipe.sadd("set_key", "member1", "member2")
pipe.hset("hash_key", "field1", "value1")
}
assertEquals("string_value", client.get("string_key"))
assertEquals(2L, client.llen("list_key"))
assertEquals(2L, client.scard("set_key"))
assertEquals("value1", client.hget("hash_key", "field1"))
}
@Test
fun `should handle empty pipeline`() {
val results = client.pipeline { }
assertEquals(emptyList<Any>(), results)
}
@Test
fun `should execute pipelines concurrently`() {
val threads = List(5) { threadIndex ->
Thread {
client.pipeline { pipe ->
repeat(100) { i ->
pipe.set("concurrent_pipe_${threadIndex}_$i", "value_$i")
}
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Verify all data was written
repeat(5) { threadIndex ->
repeat(100) { i ->
assertEquals("value_$i", client.get("concurrent_pipe_${threadIndex}_$i"))
}
}
}
// ========== Exception Handling Tests ==========
@Test
fun `should handle connection timeout gracefully`() {
// Use unreachable IP address
assertThrows<Exception> {
RedisClient.create(
host = "192.0.2.1", // TEST-NET-1, guaranteed unreachable
port = 6379,
timeout = 100,
maxWaitMillis = 500
).use { testClient ->
testClient.set("key", "value")
}
}
}
@Test
fun `should handle invalid operations`() {
// Try to perform list operation on string value
client.set("string_key", "string_value")
assertThrows<Exception> {
client.lpush("string_key", "item")
}
}
@Test
fun `should handle connection errors in execute`() {
val badClient = RedisClient.create(
host = "192.0.2.1",
port = 6379,
timeout = 100
)
assertThrows<JedisConnectionException> {
badClient.execute { it.ping() }
}
badClient.close()
}
@Test
fun `should handle operations on closed client`() {
val testClient = RedisClient.create(host = testHost, port = testPort)
testClient.close()
assertTrue(testClient.isClosed())
// Operations on closed client should fail
assertThrows<Exception> {
testClient.set("key", "value")
}
}
// ========== Resource Cleanup Tests ==========
@Test
fun `should auto-close with use block`() {
val testClient = RedisClient.create(host = testHost, port = testPort)
testClient.use { client ->
client.set("use_test", "value")
assertEquals("value", client.get("use_test"))
}
// Verify it's closed
assertTrue(testClient.isClosed())
}
@Test
fun `should handle multiple close calls safely`() {
val testClient = RedisClient.create(host = testHost, port = testPort)
testClient.close()
assertTrue(testClient.isClosed())
// Multiple close calls should not throw
assertDoesNotThrow {
testClient.close()
}
}
@Test
fun `should cleanup resources in finally block`() {
val testClient = RedisClient.create(host = testHost, port = testPort)
try {
testClient.set("key", "value")
throw RuntimeException("Test exception")
} catch (e: RuntimeException) {
// Expected
} finally {
testClient.close()
}
assertTrue(testClient.isClosed())
}
// ========== Edge Cases and Boundary Tests ==========
@Test
fun `should handle empty string values`() {
client.set("empty", "")
assertEquals("", client.get("empty"))
}
@Test
fun `should handle special characters in keys and values`() {
val specialKey = "key:with:special:chars:🔑"
val specialValue = "value with spaces, symbols !@#$%^&*(), and emoji 🎉"
client.set(specialKey, specialValue)
assertEquals(specialValue, client.get(specialKey))
}
@Test
fun `should handle very long keys and values`() {
val longKey = "k" + "x".repeat(1000)
val longValue = "v" + "y".repeat(10000)
client.set(longKey, longValue)
assertEquals(longValue, client.get(longKey))
}
@Test
fun `should handle null returns correctly`() {
assertNull(client.get("non_existent_key"))
assertNull(client.hget("non_existent_hash", "field"))
assertNull(client.lpop("non_existent_list"))
}
@Test
fun `should handle zero and negative numbers`() {
client.set("zero", "0")
assertEquals(0L, client.get("zero")?.toLong())
client.set("negative", "-100")
assertEquals(-100L, client.get("negative")?.toLong())
assertEquals(1L, client.incrBy("zero", 1))
assertEquals(-99L, client.incrBy("negative", 1))
}
@Test
fun `should handle boundary values for increment operations`() {
client.set("max", Long.MAX_VALUE.toString())
// Incrementing max long should overflow (Redis behavior)
assertThrows<Exception> {
client.incr("max")
}
}
@Test
fun `should handle multiple deletes`() {
client.set("key1", "value1")
client.set("key2", "value2")
client.set("key3", "value3")
val deleted = client.del("key1", "key2", "key3", "non_existent")
assertEquals(3L, deleted)
}
// ========== Integration Tests ==========
@Test
fun `should work with real-world scenario - user session management`() {
val userId = "user123"
val sessionId = "session_${System.currentTimeMillis()}"
// Create session
client.transaction { tx ->
tx.hset("session:$sessionId", "userId", userId)
tx.hset("session:$sessionId", "createdAt", System.currentTimeMillis().toString())
tx.expire("session:$sessionId", 3600) // 1 hour expiration
}
// Verify session
val storedUserId = client.hget("session:$sessionId", "userId")
assertEquals(userId, storedUserId)
val ttl = client.ttl("session:$sessionId")
assertTrue(ttl > 0 && ttl <= 3600)
}
@Test
fun `should work with real-world scenario - distributed counter`() {
val counterKey = "page_views"
// Simulate concurrent access
val threads = List(10) { threadIndex ->
Thread {
repeat(100) {
client.incr(counterKey)
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Verify count is accurate
assertEquals(1000L, client.get(counterKey)?.toLong())
}
@Test
fun `should work with real-world scenario - leaderboard with sorted set`() {
// Batch add scores
client.pipeline { pipe ->
pipe.zadd("leaderboard", 100.0, "player1")
pipe.zadd("leaderboard", 200.0, "player2")
pipe.zadd("leaderboard", 150.0, "player3")
pipe.zadd("leaderboard", 300.0, "player4")
}
// Get leaderboard (descending order)
val topPlayers = client.zrevrange("leaderboard", 0, 2)
assertEquals(listOf("player4", "player2", "player3"), topPlayers)
// Get player rank
val rank = client.zrank("leaderboard", "player3")
assertNotNull(rank)
}
@Test
fun `should work with real-world scenario - cache with expiration`() {
val cacheKey = "cache:user:123"
val userData = """{"id":123,"name":"John","email":"john@example.com"}"""
// Set cache with 5 second expiration
client.setex(cacheKey, 5, userData)
// Read immediately
assertEquals(userData, client.get(cacheKey))
// Check TTL
val ttl = client.ttl(cacheKey)
assertTrue(ttl in 1..5)
// Delete cache
client.del(cacheKey)
assertNull(client.get(cacheKey))
}
@Test
fun `should work with real-world scenario - rate limiting`() {
val userId = "user456"
val rateLimitKey = "rate_limit:$userId"
val maxRequests = 10L
val windowSeconds = 60L
// Simulate rate limiting
val currentRequests = client.incr(rateLimitKey)
if (currentRequests == 1L) {
client.expire(rateLimitKey, windowSeconds)
}
// Check if within limit
assertTrue(currentRequests <= maxRequests)
// Verify TTL is set
val ttl = client.ttl(rateLimitKey)
assertTrue(ttl > 0)
}
@Test
fun `should work with real-world scenario - pub-sub message queue simulation`() {
// Use list as a simple message queue
val queueKey = "message_queue"
// Producer: push messages
client.pipeline { pipe ->
repeat(10) { i ->
pipe.lpush(queueKey, "message_$i")
}
}
// Consumer: pop messages
val messages = mutableListOf<String>()
repeat(10) {
client.rpop(queueKey)?.let { messages.add(it) }
}
assertEquals(10, messages.size)
assertEquals(0L, client.llen(queueKey))
}
@Test
fun `should work with real-world scenario - distributed lock simulation`() {
val lockKey = "lock:resource:123"
val lockValue = "lock_${System.currentTimeMillis()}"
// Acquire lock (setnx returns 1 if successful)
val acquired = client.setnx(lockKey, lockValue)
assertEquals(1L, acquired)
// Set expiration to prevent deadlock
client.expire(lockKey, 10)
// Try to acquire again (should fail)
val acquiredAgain = client.setnx(lockKey, "another_value")
assertEquals(0L, acquiredAgain)
// Release lock
client.del(lockKey)
// Should be able to acquire now
val acquiredAfterRelease = client.setnx(lockKey, lockValue)
assertEquals(1L, acquiredAfterRelease)
}
}