diff --git a/pom.xml b/pom.xml
index 2b404e14..0e77b0c1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,6 +33,13 @@
pom
import
+
+ org.testcontainers
+ testcontainers-bom
+ 2.0.5
+ pom
+ import
+
@@ -134,6 +141,16 @@
org.slf4j
slf4j-simple
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
redis.clients
jedis
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
index b50efe50..05ac8c46 100644
--- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
@@ -120,7 +120,17 @@ public static String getenvNonNull(String key) {
*/
public static synchronized void overwrite(Path path) {
if (Files.exists(path)) {
- dotenv = Dotenv.configure().filename(path.toString()).load();
+ String directory;
+ if (path.getParent() != null) {
+ directory = path.getParent().toAbsolutePath().toString();
+ } else {
+ directory = Path.of("").toAbsolutePath().toString();
+ }
+
+ dotenv = Dotenv.configure()
+ .directory(directory)
+ .filename(path.getFileName().toString())
+ .load();
} else {
logger.warn("No .env file found at '{}', using system environment variables", path);
}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheRecoveryIntegrationTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheRecoveryIntegrationTest.java
new file mode 100644
index 00000000..bbd5a3e5
--- /dev/null
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheRecoveryIntegrationTest.java
@@ -0,0 +1,304 @@
+/* Licensed under MIT 2025-2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Stream;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import edu.kit.kastel.sdq.lissa.ratlr.Evaluation;
+import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment;
+
+/**
+ * Integration tests for cache recovery scenarios across different cache layers.
+ * These tests verify that data can be fully recovered when transferring between
+ * local files and remote cache systems like Redis.
+ */
+@NullMarked
+@Testcontainers
+class CacheRecoveryIntegrationTest {
+
+ @Container
+ static final GenericContainer> redisContainer =
+ new GenericContainer<>("redis:latest").withExposedPorts(6379).withReuse(true);
+
+ @TempDir
+ static Path tempDir;
+
+ private static Path toRedisEnv;
+ private static Path toLocalEnv;
+
+ @BeforeAll
+ static void setUpRedis() throws IOException {
+ String redisUrl = "redis://" + redisContainer.getHost() + ":" + redisContainer.getMappedPort(6379);
+
+ String baseEnv = """
+ OLLAMA_EMBEDDING_HOST=http://localhost:11434
+ OLLAMA_HOST=http://localhost:11434
+ OPENAI_ORGANIZATION_ID=DUMMY
+ OPENAI_API_KEY=DUMMY
+ CACHE_REPLACEMENT_STRATEGY=ERROR
+ REDIS_URL=%s
+ """.formatted(redisUrl);
+
+ toRedisEnv = tempDir.resolve(".env-to-redis");
+ Files.writeString(toRedisEnv, baseEnv + "CACHE_HIERARCHY=LOCAL,REDIS\n");
+
+ toLocalEnv = tempDir.resolve(".env-to-local");
+ Files.writeString(toLocalEnv, baseEnv + "CACHE_HIERARCHY=REDIS,LOCAL\n");
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ deleteDir("src/test/resources/warc/temp/");
+ try (var jedis = new redis.clients.jedis.Jedis(redisContainer.getHost(), redisContainer.getMappedPort(6379))) {
+ jedis.flushAll();
+ assertEquals(0, jedis.dbSize(), "Redis should be empty before test");
+ }
+ }
+
+ @AfterAll
+ static void tearDown() throws IOException {
+ deleteDir("src/test/resources/warc/temp/");
+ }
+
+ // ==================== Cache Recovery Integration Tests ====================
+
+ /**
+ * Test the full cache recovery process from a local cache to a remote cache.
+ * This test simulates a scenario where the local cache is initially used to backfill an empty Redis cache,
+ * and then the local cache is recovered from Redis.
+ * It verifies that the initial local caches contents are fully recovered to the new local cache.
+ */
+ @Test
+ @DisplayName("Cache recovery: Local → Redis → Local file recovery")
+ void testCacheRecoveryIntegration() throws Exception {
+ // ===== PHASE 1: Redis backfill from legacy cache =====
+ // Setup: Local (legacy) as primary, empty Redis as secondary
+ runEvaluation("src/test/resources/warc/config.json", toRedisEnv);
+ System.out.println("Legacy cache backfilled to Redis");
+ // ===== PHASE 2: Local cache recovery from Redis =====
+ // Setup: Redis as primary, new local cache as secondary
+ runEvaluation("src/test/resources/warc/config-without-cache.json", toLocalEnv);
+ System.out.println("Local cache recovered from Redis");
+
+ assertCacheFilesIdentical(
+ Path.of("src/test/resources/warc/cache/SimpleClassifier_gpt-4o-mini-2024-07-18_133742243.json"),
+ Path.of("src/test/resources/warc/temp/cache/SimpleClassifier_gpt-4o-mini-2024-07-18_133742243.json"));
+ assertCacheFilesIdentical(
+ Path.of("src/test/resources/warc/cache/OpenAiEmbeddingCreator_text-embedding-3-large.json"),
+ Path.of("src/test/resources/warc/temp/cache/OpenAiEmbeddingCreator_text-embedding-3-large.json"));
+ }
+
+ /**
+ * This test fills the missing entries in a partial local cache from Redis.
+ * This test simulates a scenario where the local cache already holds some values while others get backfilled
+ * from the remote redis cache.
+ */
+ @Test
+ @DisplayName("Cache completion: Partial local cache completed from Redis")
+ void testPartialCacheCompletionIntegration() throws Exception {
+ // ===== PHASE 1: Fill Redis from complete local cache =====
+ runEvaluation("src/test/resources/warc/config.json", toRedisEnv);
+
+ // ===== SETUP: Copy complete cache to temp dir and remove some entries =====
+ Path sourceCacheFile =
+ Path.of("src/test/resources/warc/cache/SimpleClassifier_gpt-4o-mini-2024-07-18_133742243.json");
+ Path tempCacheDir = Path.of("src/test/resources/warc/temp/cache");
+ Files.createDirectories(tempCacheDir);
+ Path tempCacheFile = tempCacheDir.resolve("SimpleClassifier_gpt-4o-mini-2024-07-18_133742243.json");
+ Files.copy(sourceCacheFile, tempCacheFile);
+
+ // Remove some entries to simulate a partial cache
+ ObjectMapper mapper = new ObjectMapper();
+ TypeReference