From e36ddd9fd855726b9736ce125e68cc61fb3f15d6 Mon Sep 17 00:00:00 2001 From: Toropanov Nikolai Date: Tue, 13 Oct 2026 20:32:07 +0300 Subject: [PATCH 1/2] feat(env-redis): data helpers on RedisSystem, public ResettableRedisRemote keys/type/del/exists/getMap/setMap/getList/setList/get/set move from RedisContainerSystem to RedisSystem as default methods built on exec(), so specs use env() the same way against a container and a remote Redis. ResettableRedisRemote.exec() selects env.redis.database on every call; its keys() hides the __ns_owner__ marker. resettableRedis() now returns RedisSystem (no cast); the 5.6.2 JVM signature returning ExternalSystem is kept as a hidden bridge. ResettableRedisRemoteTest runs against Testcontainers, or against a running Redis with ENV_REMOTE=true (TEST_REDIS_HOST/PORT/DB). Co-Authored-By: Claude Code --- .../adven27/env/redis/RedisContainerSystem.kt | 28 +- .../github/adven27/env/redis/RedisSystem.kt | 39 ++- .../env/redis/ResettableRedisRemote.kt | 46 ++- .../kotlin/redis/ResettableRedisRemoteTest.kt | 281 +++++++++++++----- 4 files changed, 271 insertions(+), 123 deletions(-) diff --git a/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisContainerSystem.kt b/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisContainerSystem.kt index a1b2d52..3e2fedc 100644 --- a/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisContainerSystem.kt +++ b/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisContainerSystem.kt @@ -43,36 +43,10 @@ open class RedisContainerSystem @JvmOverloads constructor( override fun running() = isRunning - fun exec(f: (KredsClient) -> T) = client.use(f) - - fun keys(pattern: String = "*", vararg types: String): Collection = exec { cl -> - runBlocking { - cl.keys(pattern) - .let { keys -> if (types.isEmpty()) keys else keys.filter { cl.type(it) in types } } - .sorted() - } - } + override fun exec(f: (KredsClient) -> T) = client.use(f) override fun clean(): String = exec { runBlocking { it.flushAll() } } - fun type(key: String): String = exec { runBlocking { it.type(key) } } - fun del(vararg keys: String) = exec { runBlocking { it.del(*keys) } } - fun exists(vararg keys: String) = exec { runBlocking { it.exists(*keys) } } - fun exists(key: String) = exec { runBlocking { it.exists(key) == 1L } } - fun getMap(key: String): Map = - exec { runBlocking { it.hgetAll(key).chunked(2).associate { it[0] to it[1] } } } - - fun setMap(key: String, field: String, value: String): Long = exec { runBlocking { it.hset(key, field to value) } } - fun setMap(key: String, map: Map): Long = - exec { cl -> runBlocking { map.toList().let { cl.hset(key, it.first(), *it.drop(1).toTypedArray()) } } } - - fun getList(key: String, start: Int = 0, stop: Int = -1) = exec { runBlocking { it.lrange(key, start, stop) } } - fun setList(key: String, vararg values: String) = - exec { runBlocking { it.rpush(key, values.first(), *values.drop(1).toTypedArray()) } } - - fun get(key: String): String? = exec { runBlocking { it.get(key) } } - fun set(key: String, value: String): String? = exec { runBlocking { it.set(key, value) } } - data class Config @JvmOverloads constructor(val host: String = "localhost", val port: Int = PORT) : ExternalSystemConfig("env.redis.host" to host, "env.redis.port" to port.toString()) diff --git a/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisSystem.kt b/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisSystem.kt index fcd7f25..5a7f0cf 100644 --- a/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisSystem.kt +++ b/env-redis/src/main/kotlin/io/github/adven27/env/redis/RedisSystem.kt @@ -1,6 +1,8 @@ package io.github.adven27.env.redis import io.github.adven27.env.core.ExternalSystem +import io.github.crackthecodeabhi.kreds.connection.KredsClient +import kotlinx.coroutines.runBlocking /** * Type-safe interface for all Redis systems in an [io.github.adven27.env.core.Environment]. @@ -16,11 +18,46 @@ import io.github.adven27.env.core.ExternalSystem * env.env("REDIS").clean() * ``` * + * Data helpers ([keys], [getMap], [setMap], ...) are built on [exec] and behave the same + * for every implementation, so specs work with `env()` both against a container and a remote Redis. + * * Contract: * - [RedisContainerSystem.clean] flushes the entire Redis instance (FLUSHALL). * - [ResettableRedisRemote.clean] flushes only its namespaced logical database (FLUSHDB), - * leaving other databases intact. + * leaving other databases intact; its [exec] always runs in that database. */ +@Suppress("TooManyFunctions", "SpreadOperator") interface RedisSystem : ExternalSystem { fun clean(): Any + + /** Runs [f] with a client connected to the database this system owns. */ + fun exec(f: (KredsClient) -> T): T + + fun keys(pattern: String = "*", vararg types: String): Collection = exec { cl -> + runBlocking { + cl.keys(pattern) + .let { keys -> if (types.isEmpty()) keys else keys.filter { cl.type(it) in types } } + .sorted() + } + } + + fun type(key: String): String = exec { runBlocking { it.type(key) } } + fun del(vararg keys: String): Long = exec { runBlocking { it.del(*keys) } } + fun exists(vararg keys: String): Long = exec { runBlocking { it.exists(*keys) } } + fun exists(key: String): Boolean = exec { runBlocking { it.exists(key) == 1L } } + fun getMap(key: String): Map = + exec { runBlocking { it.hgetAll(key).chunked(2).associate { it[0] to it[1] } } } + + fun setMap(key: String, field: String, value: String): Long = exec { runBlocking { it.hset(key, field to value) } } + fun setMap(key: String, map: Map): Long = + exec { cl -> runBlocking { map.toList().let { cl.hset(key, it.first(), *it.drop(1).toTypedArray()) } } } + + fun getList(key: String, start: Int = 0, stop: Int = -1): List = + exec { runBlocking { it.lrange(key, start, stop) } } + + fun setList(key: String, vararg values: String): Long = + exec { runBlocking { it.rpush(key, values.first(), *values.drop(1).toTypedArray()) } } + + fun get(key: String): String? = exec { runBlocking { it.get(key) } } + fun set(key: String, value: String): String? = exec { runBlocking { it.set(key, value) } } } diff --git a/env-redis/src/main/kotlin/io/github/adven27/env/redis/ResettableRedisRemote.kt b/env-redis/src/main/kotlin/io/github/adven27/env/redis/ResettableRedisRemote.kt index fd06948..97e2d18 100644 --- a/env-redis/src/main/kotlin/io/github/adven27/env/redis/ResettableRedisRemote.kt +++ b/env-redis/src/main/kotlin/io/github/adven27/env/redis/ResettableRedisRemote.kt @@ -4,29 +4,39 @@ import io.github.adven27.env.core.ExternalSystem import io.github.adven27.env.core.ExternalSystemConfig import io.github.adven27.env.core.shouldReset import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.github.crackthecodeabhi.kreds.connection.KredsClient import io.github.crackthecodeabhi.kreds.connection.newClient import kotlinx.coroutines.runBlocking @Suppress("unused") -fun resettableRedis(props: Map): ExternalSystem = ResettableRedisRemote(props) +@JvmName("resettableRedisSystem") +fun resettableRedis(props: Map): RedisSystem = ResettableRedisRemote(props) -private class ResettableRedisRemote(private val props: Map) : RedisSystem { +/** Keeps the 5.6.2 JVM signature for binaries compiled against it. */ +@Suppress("unused") +@Deprecated("Binary compatibility only", level = DeprecationLevel.HIDDEN) +@JvmName("resettableRedis") +fun resettableRedisCompat(props: Map): ExternalSystem = ResettableRedisRemote(props) + +/** + * Redis that is already running (e.g. a shared test stack): owns only the logical database `env.redis.database`. + * Every call, including the [RedisSystem] data helpers, runs in that database. + */ +class ResettableRedisRemote(private val props: Map) : RedisSystem { override val config: ExternalSystemConfig = ExternalSystemConfig(props) + private val endpoint get() = Endpoint.from("${props.getValue("env.redis.host")}:${props.getValue("env.redis.port")}") + private val database get() = props.getValue("env.redis.database").toULong() + override fun start(fixedEnv: Boolean) { props.forEach(System::setProperty) if (!shouldReset()) return - val host = props.getValue("env.redis.host") - val port = props.getValue("env.redis.port") - val database = props.getValue("env.redis.database").toULong() val nsOwner = System.getenv("TEST_NS_OWNER") ?: System.getProperty("TEST_NS_OWNER") ?: "" - - newClient(Endpoint.from("$host:$port")).use { cl -> + exec { cl -> runBlocking { - cl.select(database) cl.flushDb() - cl.set("__ns_owner__", "$nsOwner port=${database.toLong()}") + cl.set(NS_OWNER_KEY, "$nsOwner port=${database.toLong()}") } } } @@ -39,10 +49,18 @@ private class ResettableRedisRemote(private val props: Map) : Re * Flushes only the namespaced logical database configured for this remote system. * Safe to call in [@BeforeExample] — does NOT flush other logical databases. */ - override fun clean(): Any = newClient(Endpoint.from("${props.getValue("env.redis.host")}:${props.getValue("env.redis.port")}")).use { cl -> - runBlocking { - cl.select(props.getValue("env.redis.database").toULong()) - cl.flushDb() - } + override fun clean(): Any = exec { runBlocking { it.flushDb() } } + + override fun exec(f: (KredsClient) -> T): T = newClient(endpoint).use { cl -> + runBlocking { cl.select(database) } + f(cl) + } + + /** Same as [RedisSystem.keys] but without the namespace owner marker, so specs see the same keys as in a container. */ + override fun keys(pattern: String, vararg types: String): Collection = + super.keys(pattern, *types).filter { it != NS_OWNER_KEY } + + companion object { + private const val NS_OWNER_KEY = "__ns_owner__" } } diff --git a/env-redis/src/test/kotlin/redis/ResettableRedisRemoteTest.kt b/env-redis/src/test/kotlin/redis/ResettableRedisRemoteTest.kt index 184ce8e..c973a5f 100644 --- a/env-redis/src/test/kotlin/redis/ResettableRedisRemoteTest.kt +++ b/env-redis/src/test/kotlin/redis/ResettableRedisRemoteTest.kt @@ -1,38 +1,61 @@ package redis import io.github.adven27.env.redis.RedisSystem +import io.github.adven27.env.redis.ResettableRedisRemote import io.github.adven27.env.redis.resettableRedis import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.github.crackthecodeabhi.kreds.connection.KredsClient import io.github.crackthecodeabhi.kreds.connection.newClient import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.testcontainers.containers.GenericContainer +import java.util.UUID +/** + * Runs against a Testcontainers Redis by default. + * With `ENV_REMOTE=true` runs against an already running Redis (`TEST_REDIS_HOST`/`TEST_REDIS_PORT`, + * default `redis:6379`) in the logical database `TEST_REDIS_DB` — the way a shared test stack provides it. + * The "other" database (0) is then shared, so the test only writes there a uniquely named guard key + * and removes it afterwards. + */ +@Suppress("TooManyFunctions") class ResettableRedisRemoteTest { - private val redis = GenericContainer("redis:7").apply { - withExposedPorts(6379) + private val remote = System.getenv("ENV_REMOTE")?.toBoolean() == true + + private val redis = if (remote) { + null + } else { + GenericContainer("redis:7").apply { withExposedPorts(6379) } } + private val host get() = redis?.host ?: System.getenv("TEST_REDIS_HOST") ?: "redis" + private val port get() = redis?.firstMappedPort ?: System.getenv("TEST_REDIS_PORT")?.toInt() ?: 6379 + private val targetDb = if (remote) System.getenv("TEST_REDIS_DB")?.toInt() ?: TARGET_DB else TARGET_DB + private val guardKey = "env_test_guard_${UUID.randomUUID()}" + + private val props get() = mapOf( + "env.redis.host" to host, + "env.redis.port" to port.toString(), + "env.redis.database" to targetDb.toString() + ) + @Before fun startContainer() { - redis.start() + redis?.start() // Seed db 0 with a key that must survive - val host = redis.host - val port = redis.firstMappedPort - newClient(Endpoint.from("$host:$port")).use { cl -> - runBlocking { - cl.select(0UL) - cl.set("db0_key", "should_survive") - cl.select(TARGET_DB.toULong()) - cl.set("existing_key", "should_be_flushed") - } + raw { + select(OTHER_DB.toULong()) + set(guardKey, "should_survive") + select(targetDb.toULong()) + set("existing_key", "should_be_flushed") } } @@ -40,7 +63,13 @@ class ResettableRedisRemoteTest { fun stopContainer() { System.clearProperty("SPECS_SUT_START") System.clearProperty("TEST_NS_OWNER") - redis.stop() + raw { + select(OTHER_DB.toULong()) + del(guardKey) + select(targetDb.toULong()) + flushDb() + } + redis?.stop() } @Test @@ -48,30 +77,21 @@ class ResettableRedisRemoteTest { System.clearProperty("SPECS_SUT_START") // gate absent → shouldReset = true System.setProperty("TEST_NS_OWNER", "test-room") - val host = redis.host - val port = redis.firstMappedPort - - val props = mapOf( - "env.redis.host" to host, - "env.redis.port" to port.toString(), - "env.redis.database" to TARGET_DB.toString() - ) - val system = resettableRedis(props) system.start(false) - newClient(Endpoint.from("$host:$port")).use { cl -> - runBlocking { - // db 0 should still have db0_key - cl.select(0UL) - assertEquals("should_survive", cl.get("db0_key")) - - // target db should be empty except for __ns_owner__ - cl.select(TARGET_DB.toULong()) - assertNull("existing_key should be flushed", cl.get("existing_key")) - val nsOwner = cl.get("__ns_owner__") - assertEquals("test-room port=$TARGET_DB", nsOwner) - } + raw { + // db 0 should still have the guard key + select(OTHER_DB.toULong()) + assertEquals("should_survive", get(guardKey)) + + // target db should be empty except for __ns_owner__ + select(targetDb.toULong()) + assertNull("existing_key should be flushed", get("existing_key")) + val nsOwner = get("__ns_owner__") + // the environment variable wins over the system property (a shared test stack sets it) + val expectedOwner = System.getenv("TEST_NS_OWNER") ?: "test-room" + assertEquals("$expectedOwner port=$targetDb", nsOwner) } } @@ -79,74 +99,173 @@ class ResettableRedisRemoteTest { fun `start with SPECS_SUT_START=false is connect-only and data survives`() { System.setProperty("SPECS_SUT_START", "false") - val host = redis.host - val port = redis.firstMappedPort - - val props = mapOf( - "env.redis.host" to host, - "env.redis.port" to port.toString(), - "env.redis.database" to TARGET_DB.toString() - ) - val system = resettableRedis(props) system.start(false) - newClient(Endpoint.from("$host:$port")).use { cl -> - runBlocking { - cl.select(TARGET_DB.toULong()) - assertEquals("should_be_flushed", cl.get("existing_key")) - } + raw { + select(targetDb.toULong()) + assertEquals("should_be_flushed", get("existing_key")) } } @Test - fun `resettable redis implements Cleanable and clean flushes only its db`() { - // Regression for #4: spec authors call env().clean() in @BeforeExample. - // ResettableRedisRemote must implement Cleanable and flush only its logical DB (not all DBs). - System.clearProperty("SPECS_SUT_START") + fun `resettable redis implements RedisSystem and clean flushes only its db`() { + // Regression for #4: spec authors call env().clean() in @BeforeExample. + // ResettableRedisRemote must flush only its logical DB (not all DBs). + val system = started() - val host = redis.host - val port = redis.firstMappedPort + // Seed: add keys to both the target DB and db 0 after start + raw { + select(OTHER_DB.toULong()) + set(guardKey, "must_survive") + select(targetDb.toULong()) + set("target_key", "will_be_cleaned") + } - val props = mapOf( - "env.redis.host" to host, - "env.redis.port" to port.toString(), - "env.redis.database" to TARGET_DB.toString() - ) + system.clean() - val system = resettableRedis(props) - system.start(false) + raw { + // db 0 must be untouched + select(OTHER_DB.toULong()) + assertEquals("db 0 must survive clean()", "must_survive", get(guardKey)) - // Seed: add keys to both the target DB and db 0 after start - newClient(Endpoint.from("$host:$port")).use { cl -> - runBlocking { - cl.select(0UL) - cl.set("db0_after_start", "must_survive") - cl.select(TARGET_DB.toULong()) - cl.set("target_key", "will_be_cleaned") - } + // target DB must be empty (clean flushes its db only) + select(targetDb.toULong()) + assertNull("target_key must be gone after clean()", get("target_key")) } + } - // system must implement RedisSystem - assertTrue("ResettableRedisRemote must implement RedisSystem", system is RedisSystem) + @Test + fun `resettableRedis returns RedisSystem and the class is public`() { + val system: RedisSystem = resettableRedis(props) + assertTrue(system is ResettableRedisRemote) - // Call clean() via the RedisSystem interface - (system as RedisSystem).clean() + val direct: RedisSystem = ResettableRedisRemote(props) + direct.start(false) + direct.set("k", "v") + assertEquals("v", direct.get("k")) + } - newClient(Endpoint.from("$host:$port")).use { cl -> - runBlocking { - // db 0 must be untouched - cl.select(0UL) - assertEquals("db 0 must survive clean()", "must_survive", cl.get("db0_after_start")) + @Test + fun `helpers write to and read from its own db, db 0 is not touched`() { + val system = started() - // target DB must be empty (clean flushes its db only) - cl.select(TARGET_DB.toULong()) - assertNull("target_key must be gone after clean()", cl.get("target_key")) + system.set("k", "v") + system.setMap("map", mapOf("a" to "1")) + system.setList("list", "1") + + raw { + select(targetDb.toULong()) + assertEquals("v", get("k")) + assertEquals(listOf("a", "1"), hgetAll("map")) + assertEquals(listOf("1"), lrange("list", 0, -1)) + + select(OTHER_DB.toULong()) + assertNull(get("k")) + assertEquals(0L, exists("map", "list")) + assertEquals("should_survive", get(guardKey)) + } + + raw { + select(OTHER_DB.toULong()) + set("other_db_only", "x") + } + try { + assertNull("helpers must not read db 0", system.get("other_db_only")) + } finally { + raw { + select(OTHER_DB.toULong()) + del("other_db_only") } } } + @Test + fun `set and get`() { + val system = started() + + assertEquals("OK", system.set("k", "v")) + assertEquals("v", system.get("k")) + assertNull(system.get("absent")) + } + + @Test + fun `setMap with map and with field, getMap`() { + val system = started() + + val map = mapOf("a" to "1", "b" to "2") + assertEquals(2L, system.setMap("map", map)) + assertEquals(map, system.getMap("map")) + + assertEquals(1L, system.setMap("map", "c", "3")) + assertEquals(map + ("c" to "3"), system.getMap("map")) + assertEquals(emptyMap(), system.getMap("absent")) + } + + @Test + fun `setList and getList with range`() { + val system = started() + + assertEquals(3L, system.setList("list", "1", "2", "3")) + assertEquals(listOf("1", "2", "3"), system.getList("list")) + assertEquals(listOf("2"), system.getList("list", 1, 1)) + } + + @Test + fun `type of keys`() { + val system = started() + system.set("k", "v") + system.setMap("map", "a", "1") + system.setList("list", "1") + + assertEquals("string", system.type("k")) + assertEquals("hash", system.type("map")) + assertEquals("list", system.type("list")) + assertEquals("none", system.type("absent")) + } + + @Test + fun `keys by pattern and types, sorted, without the ns owner marker`() { + val system = started() + system.set("k", "v") + system.setMap("map", "a", "1") + system.setList("list", "1") + + assertEquals(listOf("k", "list", "map"), system.keys()) + assertEquals(listOf("list"), system.keys("l*")) + assertEquals(listOf("map"), system.keys(types = arrayOf("hash"))) + assertEquals(listOf("k", "map"), system.keys("*", "hash", "string")) + } + + @Test + fun `exists and del`() { + val system = started() + system.set("k", "v") + system.setMap("map", "a", "1") + + assertEquals(2L, system.exists("k", "map", "absent")) + assertTrue(system.exists("k")) + + assertEquals(1L, system.del("k")) + assertFalse(system.exists("k")) + assertTrue(system.exists("map")) + } + + @Test + fun `exec runs on its own db`() { + val system = started() + system.setMap("map", mapOf("a" to "1", "b" to "2")) + + assertEquals(listOf("a", "b"), system.exec { runBlocking { it.hkeys("map") } }) + } + + private fun started(): RedisSystem = resettableRedis(props).also { it.start(false) } + + private fun raw(block: suspend KredsClient.() -> Unit) = + newClient(Endpoint.from("$host:$port")).use { cl -> runBlocking { cl.block() } } + companion object { private const val TARGET_DB = 5 + private const val OTHER_DB = 0 } } From 48beae468c1df3ab22d9d94b47ceef2cf75fa4e9 Mon Sep 17 00:00:00 2001 From: Toropanov Nikolai Date: Wed, 14 Oct 2026 15:15:54 +0300 Subject: [PATCH 2/2] chore(env): 6.1.1 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 437d592..98db3f8 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { ext.testContainers_version = '2.0.5' ext.wiremock_version = '3.13.0' ext.klogging_version = '7.0.0' - ext.libVersion = "6.1.0" + ext.libVersion = "6.1.1" ext.libGroup = 'io.github.toronik' repositories { mavenCentral()