Skip to content

Task 3-1: Integration Test: Redis State Sync

Phase: 3 Priority: Medium Module: RedisMachineRepository, ConnectedClient Depends on: task-1-1 Reference: docs/BountyHunter-ControlServer/details/feature-machine-management/SPEC.md

Background

RedisMachineRepository là single point of truth cho machine status trong ControlServer. Sau fix task-1-1 (externalized TTL), cần integration test để verify (1) TTL đúng với config, không còn hardcoded 11s, (2) status được update đúng sau mỗi machine event, và (3) JedisPool không bị connection leak dưới concurrent load. Dùng embedded Redis (Testcontainers hoặc embedded-redis library) để test mà không cần Redis server thật.

Tasks

Note: Testcontainers (testcontainers-redis) là lựa chọn tốt nhất vì dùng Docker container Redis thật. Nếu Docker không available trong CI, dùng it.ozimov:embedded-redis (nhẹ hơn). Verify TTL bằng jedis.ttl(key) ngay sau save() — TTL có thể nhỏ hơn config value 1-2 giây do execution time, dùng assertThat(ttl).isBetween(configTtl - 2, configTtl). JedisPool leak: verify jedisPool.getNumActive() trở về 0 sau test.

  • [ ] Setup embedded Redis trong test:
@SpringBootTest
@ActiveProfiles("test")
class RedisStateSyncIntegrationTest {

    // Option A: Testcontainers
    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    // Option B: embedded-redis (không cần Docker)
    // @BeforeAll static void startRedis() { embeddedRedis = new RedisServer(16379); embeddedRedis.start(); }

    @Autowired
    private RedisMachineRepository repository;

    @Autowired
    private JedisPool jedisPool;

    @BeforeEach
    void clearRedis() {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.flushDb();
        }
    }
}
  • [ ] Test scenario 1: Machine connect → Redis key created với đúng status:
@Test
void whenMachineConnects_thenRedisKeyCreatedWithStatus0() {
    String macIp = "00:11:22:33:44:55";
    repository.save(0, macIp);

    try (Jedis jedis = jedisPool.getResource()) {
        String key = "RCraneMachineStatus:" + macIp;
        assertThat(jedis.exists(key)).isTrue();
        assertThat(jedis.hget(key, "status")).isEqualTo("0");
    }
}
  • [ ] Test scenario 2: Status update → Redis updated đúng:
@Test
void whenMachineStatusChanges_thenRedisUpdatedWithNewStatus() {
    String macIp = "AA:BB:CC:DD:EE:FF";
    repository.save(0, macIp);  // initial: status=0
    repository.save(1, macIp);  // update: status=1

    try (Jedis jedis = jedisPool.getResource()) {
        assertThat(jedis.hget("RCraneMachineStatus:" + macIp, "status")).isEqualTo("1");
    }
}
  • [ ] Test scenario 3: TTL đúng với config (validates task-1-1 fix):
@Test
void whenSave_thenRedisTtlMatchesConfig() {
    int configuredTtl = 30; // value from test config: redis.machine.ttl-seconds=30
    String macIp = "11:22:33:44:55:66";
    repository.save(0, macIp);

    try (Jedis jedis = jedisPool.getResource()) {
        long ttl = jedis.ttl("RCraneMachineStatus:" + macIp);
        // TTL should be within 2 seconds of configured value
        assertThat(ttl).isBetween((long) configuredTtl - 2, (long) configuredTtl);
    }
}
  • [ ] Test scenario 4: Concurrent save() calls → no JedisPool connection leak:
@Test
void whenConcurrentSaves_thenNoConnectionLeak() throws InterruptedException {
    int threadCount = 20;
    CountDownLatch latch = new CountDownLatch(threadCount);
    ExecutorService pool = Executors.newFixedThreadPool(threadCount);

    for (int i = 0; i < threadCount; i++) {
        final String mac = String.format("00:00:00:00:00:%02X", i);
        pool.submit(() -> {
            for (int j = 0; j < 50; j++) {
                repository.save(j % 2, mac);
            }
            latch.countDown();
        });
    }

    latch.await(10, TimeUnit.SECONDS);
    pool.shutdown();

    // All connections returned to pool
    assertThat(jedisPool.getNumActive()).isEqualTo(0);
}
  • [ ] Test scenario 5: Redis key expire tự nhiên sau TTL:
@Test
void whenKeyExpires_thenGetStatusReturnsNull() throws InterruptedException {
    // Use short TTL for test: redis.machine.ttl-seconds=2 (test profile)
    repository.save(1, "FF:EE:DD:CC:BB:AA");
    Thread.sleep(3000); // wait for expiry

    assertThat(repository.getStatus("FF:EE:DD:CC:BB:AA")).isNull();
}
  • [ ] Tạo application-test.properties với Redis test config:
redis.machine.ttl-seconds=30
# Point to embedded Redis port
spring.redis.host=localhost
spring.redis.port=${embedded.redis.port}

Verification / Acceptance Criteria

  • [ ] jedis.hget("RCraneMachineStatus:{mac}", "status") trả đúng value sau mỗi save() call
  • [ ] jedis.ttl("RCraneMachineStatus:{mac}")redis.machine.ttl-seconds config value (không phải hardcoded 11)
  • [ ] 1000 concurrent save() calls → jedisPool.getNumActive() == 0 sau test (no leak)
  • [ ] Key tự expire sau TTL → getStatus() trả null
  • [ ] Tất cả tests pass với embedded Redis (không cần external Redis server)

Files to Modify

  • src/test/java/.../integration/RedisStateSyncIntegrationTest.java ← file mới tạo
  • src/test/resources/application-test.properties
  • build.gradle hoặc pom.xml (thêm Testcontainers hoặc embedded-redis test dependency)