diff --git a/CMakeLists.txt b/CMakeLists.txt index 9116e7e..73212ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,9 @@ if(GTest_FOUND) tests/unit/RemoveTest.cpp tests/unit/RemoveWithVersionTest.cpp tests/unit/ReplaceWithVersionTest.cpp + tests/unit/PutIfAbsentTest.cpp + tests/unit/ReplaceTest.cpp + tests/unit/ContainsKeyTest.cpp tests/unit/HashAwareRoutingTest.cpp ) @@ -140,6 +143,18 @@ if(GTest_FOUND) tests/integration/ReplaceWithVersionIntegrationTest.cpp ) + add_executable(putifabsent_integration_tests + tests/integration/PutIfAbsentIntegrationTest.cpp + ) + + add_executable(replace_integration_tests + tests/integration/ReplaceIntegrationTest.cpp + ) + + add_executable(containskey_integration_tests + tests/integration/ContainsKeyIntegrationTest.cpp + ) + # Topology integration tests (multi-server cluster) add_executable(topology_integration_tests tests/integration/TopologyChangeTest.cpp @@ -199,6 +214,27 @@ if(GTest_FOUND) # Note: NOT gtest_main - we provide our own main() ) + target_link_libraries(putifabsent_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + + target_link_libraries(replace_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + + target_link_libraries(containskey_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + target_link_libraries(topology_integration_tests PRIVATE hotrod-client @@ -244,6 +280,9 @@ if(GTest_FOUND) add_test(NAME RemoveIntegrationTests COMMAND remove_integration_tests) add_test(NAME RemoveWithVersionIntegrationTests COMMAND removewithversion_integration_tests) add_test(NAME ReplaceWithVersionIntegrationTests COMMAND replacewithversion_integration_tests) + add_test(NAME PutIfAbsentIntegrationTests COMMAND putifabsent_integration_tests) + add_test(NAME ReplaceIntegrationTests COMMAND replace_integration_tests) + add_test(NAME ContainsKeyIntegrationTests COMMAND containskey_integration_tests) add_test(NAME TopologyIntegrationTests COMMAND topology_integration_tests) add_test(NAME ConcurrentClientsTests COMMAND concurrent_clients_tests) add_test(NAME HashAwareRoutingIntegrationTests COMMAND hash_aware_routing_integration_tests) @@ -278,6 +317,18 @@ if(GTest_FOUND) WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker" ) + set_tests_properties(PutIfAbsentIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker" + ) + set_tests_properties(ReplaceIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker" + ) + set_tests_properties(ContainsKeyIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker" + ) set_tests_properties(TopologyIntegrationTests PROPERTIES WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker;topology;multi-server" diff --git a/docs/STATUS.md b/docs/STATUS.md index 5569545..813e247 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -28,12 +28,12 @@ _(Full per-session workflow: [`WORKFLOW.md`](WORKFLOW.md).)_ _The 1–3 concrete things to do next. Keep this short and current._ -1. **Step 10 — Metadata operations, continued.** `getWithMetadata` (0x1B/0x1C), - `removeWithVersion` (0x0D/0x0E) and `replaceWithVersion` (0x09/0x0A) are now - **shipped** (see "Working and shipped"). Next in the recommended order: the - conditionals **`putIfAbsent`** (0x05/0x06), **`replace`** (0x07/0x08), - **`containsKey`** (0x0F/0x10). Version comes from - `getWithMetadata().get()->metadata.version`. +1. **Step 10 is complete.** `getWithMetadata` (0x1B/0x1C), `removeWithVersion` + (0x0D/0x0E), `replaceWithVersion` (0x09/0x0A) and the conditionals + `putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10) + are all **shipped** (see "Working and shipped"). Next roadmap step: + **Step 11 — Error handling** (ERROR response 0x50, exception hierarchy, + retry on transient errors). 2. **Benchmark the multiplexing path** — the async rewrite targets 5–10× concurrent throughput; this has not been measured yet. 3. **Small cleanup:** read header "other params" when `paramCount > 0` @@ -53,7 +53,7 @@ is authoritative for what's done._ - [x] `getWithMetadata` (0x1B/0x1C) — **shipped 2026-09-17** (8 unit + 7 integration tests) - [x] `removeWithVersion` (0x0D/0x0E) — **shipped 2026-09-19** (7 unit + 5 integration tests). Returns `future` (matches Java `boolean removeWithVersion(K, long)`); added `Codec::writeLong`/`readLong` (fixed 8-byte BE). Parser drains the `*_WITH_PREVIOUS` (0x03/0x04) body defensively though FORCE_RETURN_VALUE isn't wired yet. Byte layout cross-checked against all three legs: this client, Java `RemoveIfUnmodifiedOperation`, and `hotrod40.ksy` (`remove_if_unmodified_request` = `key: lp_bytes` + `entry_version: s8`, big-endian per `meta.endian: be`). - [x] `replaceWithVersion`/REPLACE_IF_UNMODIFIED (0x09/0x0A) — **shipped 2026-09-20** (5 unit + 5 integration tests). Returns `future`; request reuses PUT's expiration byte (high nibble=lifespan, low=maxIdle) + `Codec::writeLong` for the version. **Schema bug found:** `hotrod40.ksy` `replace_if_unmodified_request` has the expiration nibbles flipped vs `put_request`/`expiration_params`; Java uses one shared `writeExpirationParams` for both, so PUT's order is authoritative. Cross-checked against client + Java `ReplaceIfUnmodifiedOperation` + (corrected reading of) the schema. - - [ ] `putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10) + - [x] `putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10) — **shipped 2026-09-20** (11 unit + 12 integration tests). `putIfAbsent`/`replace` reuse PUT's request layout (`key` + expiration byte + `value`) and return `future>` — the previous/replaced entry when `previousValue` (FORCE_RETURN_VALUE) is set, `nullopt` otherwise; parsers drain the `*_WITH_PREVIOUS` (0x03/0x04) body defensively. `containsKey` is a key-only request returning `future` from the status (no response body). Cross-checked against Java `PutIfAbsentOperation`/`ReplaceOperation`/`ContainsKeyOperation` and `hotrod40.ksy` (put_if_absent/replace → `put_request`/`put_response`; contains_key → `key_request`, status-only response). Java ref: `GetWithMetadataOperation`, `ReplaceIfUnmodifiedOperation`. See the [step-by-step plan](#plan-metadata-operations-step-10) below. @@ -99,6 +99,11 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._ returns `future` (removed?), version from `getWithMetadata` - **replaceWithVersion** (0x09/0x0A) — version-based conditional replace (CAS); returns `future` (replaced?); request = key + expiration + version + value +- **putIfAbsent** (0x05/0x06) — store only if key absent; returns + `future>` (existing entry when `previousValue` set) +- **replace** (0x07/0x08) — store only if key present; returns + `future>` (replaced entry when `previousValue` set) +- **containsKey** (0x0F/0x10) — key-existence test; returns `future` - **Async API:** all operations return `std::future` / `std::optional` - **MultiplexedConnection:** true async — dedicated read-loop thread, `messageId → promise` pending map, `execute()` used by all four operations. @@ -111,8 +116,8 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._ portability and `-Werror` build parity also landed (Sept 2026). **Test status (verified 2026-09-20):** -- Unit: **165/165** passing (`./build/unit_tests`, <1s) -- Integration: **63/63** passing across 11 suites (`ctest`, spins up Docker +- Unit: **176/176** passing (`./build/unit_tests`, <1s) +- Integration: **75/75** passing across 14 suites (`ctest`, spins up Docker Infinispan single-server + multi-node clusters), now also green on Linux CI. The interlaced distributed tests (`ConcurrentMultiServerTest`) were fixed to tolerate a GET racing ahead of its PUT — a `nullopt` is expected, only a @@ -174,17 +179,17 @@ on 2026-09-16. That `.ksy` is the wire-format source of truth for protocol |-----------|-----|------|----------| | put | 0x01 | 0x02 | ✅ | | get | 0x03 | 0x04 | ✅ | -| putIfAbsent | 0x05 | 0x06 | ❌ | -| replace (only if present) | 0x07 | 0x08 | ❌ | +| putIfAbsent | 0x05 | 0x06 | ✅ | +| replace (only if present) | 0x07 | 0x08 | ✅ | | **replaceWithVersion** (replaceIfUnmodified) | **0x09** | **0x0A** | ✅ | | remove | 0x0B | 0x0C | ✅ | | **removeWithVersion** (removeIfUnmodified) | **0x0D** | **0x0E** | ✅ | -| containsKey | 0x0F | 0x10 | ❌ | +| containsKey | 0x0F | 0x10 | ✅ | | getWithVersion | 0x11 | 0x12 | ❌ | | **getWithMetadata** | **0x1B** | **0x1C** | ✅ | -Opcode constants are defined in `include/hotrod/HeaderCodec.h` (`Opcodes` -namespace) — currently only PUT/GET/REMOVE/PING. Add the new ones there. +Opcode constants are defined in `include/hotrod/HeaderCodec.h` (`Opcode` +namespace). All Step 10 opcodes above are present; add new ones there. ### Recommended order `getWithMetadata` first (it yields the entry version), then the two @@ -192,7 +197,7 @@ version-based writes that depend on it, then the simpler conditionals: 1. ~~**getWithMetadata** (0x1B/0x1C)~~ ✅ shipped 2026-09-17 2. ~~**removeWithVersion** (0x0D/0x0E)~~ ✅ shipped 2026-09-19 3. ~~**replaceWithVersion** (0x09/0x0A)~~ ✅ shipped 2026-09-20 -4. putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10) ← next session +4. ~~putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10)~~ ✅ shipped 2026-09-20 — **Step 10 complete** ### Step-by-step: getWithMetadata (the entry point) diff --git a/include/hotrod/HeaderCodec.h b/include/hotrod/HeaderCodec.h index c23f4da..469a1db 100644 --- a/include/hotrod/HeaderCodec.h +++ b/include/hotrod/HeaderCodec.h @@ -32,12 +32,18 @@ namespace Opcode { const uint8_t PUT_RESPONSE = 0x02; const uint8_t GET_REQUEST = 0x03; const uint8_t GET_RESPONSE = 0x04; + const uint8_t PUT_IF_ABSENT_REQUEST = 0x05; + const uint8_t PUT_IF_ABSENT_RESPONSE = 0x06; + const uint8_t REPLACE_REQUEST = 0x07; + const uint8_t REPLACE_RESPONSE = 0x08; const uint8_t REPLACE_WITH_VERSION_REQUEST = 0x09; // replaceIfUnmodified const uint8_t REPLACE_WITH_VERSION_RESPONSE = 0x0A; const uint8_t REMOVE_REQUEST = 0x0B; const uint8_t REMOVE_RESPONSE = 0x0C; const uint8_t REMOVE_WITH_VERSION_REQUEST = 0x0D; // removeIfUnmodified const uint8_t REMOVE_WITH_VERSION_RESPONSE = 0x0E; + const uint8_t CONTAINS_KEY_REQUEST = 0x0F; + const uint8_t CONTAINS_KEY_RESPONSE = 0x10; const uint8_t GET_WITH_METADATA_REQUEST = 0x1B; const uint8_t GET_WITH_METADATA_RESPONSE = 0x1C; const uint8_t PING_REQUEST = 0x17; diff --git a/include/hotrod/RemoteCache.h b/include/hotrod/RemoteCache.h index 9287db4..1b7b7bb 100644 --- a/include/hotrod/RemoteCache.h +++ b/include/hotrod/RemoteCache.h @@ -100,6 +100,68 @@ class RemoteCache { std::future> put(const ByteArray &key, const ByteArray &value, uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false); + /** + * PUT_IF_ABSENT operation - store key-value pair only if the key is absent. + * + * Opcode: 0x05 (PUT_IF_ABSENT_REQUEST) → 0x06 (PUT_IF_ABSENT_RESPONSE) + * + * Stores the entry only if no value is currently associated with the key. + * If the key already exists the entry is left untouched. + * + * @param key The key as raw bytes + * @param value The value as raw bytes + * @param lifespan Entry lifespan in seconds (0 = immortal) + * @param maxIdle Max idle time in seconds (0 = no max idle) + * @param previousValue If true, request previous value with metadata (Protocol 4.0+) + * @return Future with optional: the existing entry that + * prevented storage (when previousValue is set and the key existed); + * nullopt if the value was stored (key was absent) + * @throws std::runtime_error on communication errors (via future.get()) + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.PutIfAbsentOperation + */ + std::future> putIfAbsent(const ByteArray &key, const ByteArray &value, + uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false); + + /** + * REPLACE operation - store key-value pair only if the key is present. + * + * Opcode: 0x07 (REPLACE_REQUEST) → 0x08 (REPLACE_RESPONSE) + * + * Replaces the entry's value only if a value is currently associated with + * the key. If the key does not exist nothing is stored. + * + * @param key The key as raw bytes + * @param value The new value as raw bytes + * @param lifespan Entry lifespan in seconds (0 = immortal) + * @param maxIdle Max idle time in seconds (0 = no max idle) + * @param previousValue If true, request previous value with metadata (Protocol 4.0+) + * @return Future with optional: the replaced (previous) + * entry (when previousValue is set and the key existed); nullopt if + * the key did not exist (nothing replaced) + * @throws std::runtime_error on communication errors (via future.get()) + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceOperation + */ + std::future> replace(const ByteArray &key, const ByteArray &value, + uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false); + + /** + * CONTAINS_KEY operation - test whether a key exists in the cache. + * + * Opcode: 0x0F (CONTAINS_KEY_REQUEST) → 0x10 (CONTAINS_KEY_RESPONSE) + * + * @param key The key as raw bytes + * @return Future: true if the key exists; false otherwise + * @throws std::runtime_error on communication errors (via future.get()) + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ContainsKeyOperation + */ + std::future containsKey(const ByteArray &key); + /** * GET_WITH_METADATA operation - retrieve value plus entry metadata. * diff --git a/src/operations/RemoteCache.cpp b/src/operations/RemoteCache.cpp index 131e9cd..1b1fc61 100644 --- a/src/operations/RemoteCache.cpp +++ b/src/operations/RemoteCache.cpp @@ -587,6 +587,162 @@ namespace hotrod }); } + std::future> RemoteCache::putIfAbsent(const ByteArray &key, const ByteArray &value, + uint64_t lifespan, uint64_t maxIdle, bool previousValue) + { + // Build PUT_IF_ABSENT request body: identical layout to PUT + // key (lp_bytes) + time_units (u1) + [lifespan vLong] + [maxIdle vLong] + // + value (lp_bytes) + // Mirrors Java PutIfAbsentOperation (extends AbstractKeyValueOperation, so + // the same key/expiration/value encoder PUT uses). + ByteArray requestBody; + Codec::writeByteArray(requestBody, key); + + uint8_t timeUnits = 0; + timeUnits |= (lifespan == 0 ? 0x07 : 0x00) << 4; // high nibble: lifespan + timeUnits |= (maxIdle == 0 ? 0x07 : 0x00); // low nibble: maxIdle + requestBody.push_back(timeUnits); + if (lifespan > 0) + Codec::writeVLong(requestBody, lifespan); + if (maxIdle > 0) + Codec::writeVLong(requestBody, maxIdle); + + Codec::writeByteArray(requestBody, value); + + // Status codes: + // 0x00 = SUCCESS (stored, key was absent) -> nullopt + // 0x01 = NOT_EXECUTED (key already existed) -> nullopt (no body) + // 0x04 = NOT_EXECUTED_WITH_PREVIOUS -> existing entry (body) + // The *_WITH_PREVIOUS variant only occurs when FORCE_RETURN_VALUE is set + // (via previousValue). We drain 0x03/0x04 bodies defensively so the shared + // read loop stays byte-aligned. + auto bodyParser = [](uint8_t status, Connection *conn, uint8_t /*protocolVersion*/, int32_t /*flags*/) -> std::any + { + if (status == 0x03 || status == 0x04) + { + EntryWithMetadata entry; + entry.metadata = conn->receiveMetadata(); + entry.value = conn->receiveByteArray(); + return entry; + } + if (status == 0x00 || status == 0x01 || status == 0x02) + { + return {}; + } + throw std::runtime_error("PUT_IF_ABSENT failed with status: " + std::to_string(status)); + }; + + MultiplexedConnection *conn = selectServerForKey(key); + auto responseFuture = conn->execute(requestBody, 0x05, 0x06, bodyParser, cacheName_, (previousValue ? 1 : 0)); + + // Transform Response → optional: the existing entry that + // blocked storage, if returned; nullopt when stored (or no body present). + return std::async(std::launch::deferred, + [responseFuture = std::move(responseFuture)]() mutable -> std::optional + { + Response resp = responseFuture.get(); + if (resp.error) + std::rethrow_exception(resp.error); + if ((resp.status == 0x03 || resp.status == 0x04) && resp.body.has_value()) + return std::any_cast(resp.body); + return std::nullopt; + }); + } + + std::future> RemoteCache::replace(const ByteArray &key, const ByteArray &value, + uint64_t lifespan, uint64_t maxIdle, bool previousValue) + { + // Build REPLACE request body: identical layout to PUT + // key (lp_bytes) + time_units (u1) + [lifespan vLong] + [maxIdle vLong] + // + value (lp_bytes) + // Mirrors Java ReplaceOperation (extends AbstractKeyValueOperation). + ByteArray requestBody; + Codec::writeByteArray(requestBody, key); + + uint8_t timeUnits = 0; + timeUnits |= (lifespan == 0 ? 0x07 : 0x00) << 4; // high nibble: lifespan + timeUnits |= (maxIdle == 0 ? 0x07 : 0x00); // low nibble: maxIdle + requestBody.push_back(timeUnits); + if (lifespan > 0) + Codec::writeVLong(requestBody, lifespan); + if (maxIdle > 0) + Codec::writeVLong(requestBody, maxIdle); + + Codec::writeByteArray(requestBody, value); + + // Status codes: + // 0x00 = SUCCESS (replaced) -> nullopt + // 0x01 = NOT_EXECUTED (key did not exist) -> nullopt (no body) + // 0x03 = SUCCESS_WITH_PREVIOUS -> previous entry (body) + // The *_WITH_PREVIOUS variant only occurs when FORCE_RETURN_VALUE is set + // (via previousValue). We drain 0x03/0x04 bodies defensively. + auto bodyParser = [](uint8_t status, Connection *conn, uint8_t /*protocolVersion*/, int32_t /*flags*/) -> std::any + { + if (status == 0x03 || status == 0x04) + { + EntryWithMetadata entry; + entry.metadata = conn->receiveMetadata(); + entry.value = conn->receiveByteArray(); + return entry; + } + if (status == 0x00 || status == 0x01 || status == 0x02) + { + return {}; + } + throw std::runtime_error("REPLACE failed with status: " + std::to_string(status)); + }; + + MultiplexedConnection *conn = selectServerForKey(key); + auto responseFuture = conn->execute(requestBody, 0x07, 0x08, bodyParser, cacheName_, (previousValue ? 1 : 0)); + + // Transform Response → optional: the replaced (previous) + // entry, if returned; nullopt when nothing was replaced (or no body). + return std::async(std::launch::deferred, + [responseFuture = std::move(responseFuture)]() mutable -> std::optional + { + Response resp = responseFuture.get(); + if (resp.error) + std::rethrow_exception(resp.error); + if ((resp.status == 0x03 || resp.status == 0x04) && resp.body.has_value()) + return std::any_cast(resp.body); + return std::nullopt; + }); + } + + std::future RemoteCache::containsKey(const ByteArray &key) + { + // Build CONTAINS_KEY request body (just the key), like GET. + ByteArray requestBody; + Codec::writeByteArray(requestBody, key); + + // The response carries no body — only the status. Java ContainsKeyOperation + // returns !isNotExist(status) && isSuccess(status). + // 0x00 = SUCCESS (key exists) -> true + // 0x01 = NOT_EXECUTED -> false + // 0x02 = KEY_DOES_NOT_EXIST -> false + auto bodyParser = [](uint8_t status, Connection * /*conn*/, uint8_t /*protocolVersion*/, int32_t /*flags*/) -> std::any + { + if (status == 0x00 || status == 0x01 || status == 0x02) + { + return {}; + } + throw std::runtime_error("CONTAINS_KEY failed with status: " + std::to_string(status)); + }; + + MultiplexedConnection *conn = selectServerForKey(key); + auto responseFuture = conn->execute(requestBody, 0x0F, 0x10, bodyParser, cacheName_); + + // Transform Response → bool (key present?). Success is 0x00. + return std::async(std::launch::deferred, + [responseFuture = std::move(responseFuture)]() mutable -> bool + { + Response resp = responseFuture.get(); + if (resp.error) + std::rethrow_exception(resp.error); + return resp.status == 0x00; + }); + } + MultiplexedConnection *RemoteCache::selectServerForKey(const ByteArray &key) { // If hash-aware routing is enabled and hash topology is available diff --git a/tests/integration/ContainsKeyIntegrationTest.cpp b/tests/integration/ContainsKeyIntegrationTest.cpp new file mode 100644 index 0000000..9b4a857 --- /dev/null +++ b/tests/integration/ContainsKeyIntegrationTest.cpp @@ -0,0 +1,101 @@ +#include +#include +#include "hotrod/RemoteCache.h" +#include "InfinispanTestEnvironment.h" + +using namespace hotrod; +using namespace hotrod::test; + +/** + * CONTAINS_KEY Integration Tests - No Auth + * + * Reference: + * - ROADMAP Step 10: conditional operations + * - Java: org.infinispan.client.hotrod.impl.operations.ContainsKeyOperation + */ + +namespace { + +void createCacheViaCLI(const std::string& cacheName) { + std::string cmd = "docker exec " + InfinispanTestEnvironment::containerID + + " bash -c \"echo 'create cache --template=org.infinispan.DIST_SYNC " + cacheName + + "' | /opt/infinispan/bin/cli.sh -c http://admin:password@localhost:11222\" >/dev/null 2>&1"; + std::ignore = system(cmd.c_str()); +} + +} // anonymous namespace + +// Test 1: existing key -> true +TEST(ContainsKeyIntegrationTest, ReturnsTrueWhenPresent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'c', 'k', '1'}; + ByteArray value = {'v'}; + cache.put(key, value).get(); + + EXPECT_TRUE(cache.containsKey(key).get()); + + cache.disconnect(); +} + +// Test 2: absent key -> false +TEST(ContainsKeyIntegrationTest, ReturnsFalseWhenAbsent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'c', 'k', 'n', 'o', 'p', 'e'}; + cache.remove(key).get(); // ensure absent + + EXPECT_FALSE(cache.containsKey(key).get()); + + cache.disconnect(); +} + +// Test 3: reflects removal +TEST(ContainsKeyIntegrationTest, ReflectsRemoval) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'c', 'k', '3'}; + ByteArray value = {'v'}; + cache.put(key, value).get(); + EXPECT_TRUE(cache.containsKey(key).get()); + + cache.remove(key).get(); + EXPECT_FALSE(cache.containsKey(key).get()); + + cache.disconnect(); +} + +// Test 4: containsKey after disconnect should throw +TEST(ContainsKeyIntegrationTest, ContainsKeyAfterDisconnect) { + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + cache.disconnect(); + + ByteArray key = {'t', 'e', 's', 't'}; + EXPECT_THROW({ + cache.containsKey(key); + }, std::runtime_error); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new InfinispanTestEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/integration/PutIfAbsentIntegrationTest.cpp b/tests/integration/PutIfAbsentIntegrationTest.cpp new file mode 100644 index 0000000..447abfb --- /dev/null +++ b/tests/integration/PutIfAbsentIntegrationTest.cpp @@ -0,0 +1,114 @@ +#include +#include +#include "hotrod/RemoteCache.h" +#include "InfinispanTestEnvironment.h" + +using namespace hotrod; +using namespace hotrod::test; + +/** + * PUT_IF_ABSENT Integration Tests - No Auth + * + * Stores only when the key is absent. Reference: + * - ROADMAP Step 10: conditional operations + * - Java: org.infinispan.client.hotrod.impl.operations.PutIfAbsentOperation + */ + +namespace { + +void createCacheViaCLI(const std::string& cacheName) { + std::string cmd = "docker exec " + InfinispanTestEnvironment::containerID + + " bash -c \"echo 'create cache --template=org.infinispan.DIST_SYNC " + cacheName + + "' | /opt/infinispan/bin/cli.sh -c http://admin:password@localhost:11222\" >/dev/null 2>&1"; + std::ignore = system(cmd.c_str()); +} + +} // anonymous namespace + +// Test 1: key absent -> value is stored +TEST(PutIfAbsentIntegrationTest, StoresWhenAbsent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'p', 'i', 'a', '1'}; + ByteArray value = {'v', '1'}; + cache.remove(key).get(); // ensure absent + + auto prev = cache.putIfAbsent(key, value).get(); + EXPECT_FALSE(prev.has_value()); // stored, no previous + + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(value, after.value()); + + cache.disconnect(); +} + +// Test 2: key present -> not overwritten +TEST(PutIfAbsentIntegrationTest, DoesNotOverwriteWhenPresent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'p', 'i', 'a', '2'}; + ByteArray first = {'f', 'i', 'r', 's', 't'}; + ByteArray second = {'s', 'e', 'c', 'o', 'n', 'd'}; + cache.put(key, first).get(); + + cache.putIfAbsent(key, second).get(); // must not overwrite + + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(first, after.value()); // original survives + + cache.disconnect(); +} + +// Test 3: key present + previousValue -> returns the existing entry +TEST(PutIfAbsentIntegrationTest, ReturnsPreviousWhenPresent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'p', 'i', 'a', '3'}; + ByteArray existing = {'e', 'x'}; + ByteArray attempt = {'n', 'o'}; + cache.put(key, existing).get(); + + auto prev = cache.putIfAbsent(key, attempt, 0, 0, true).get(); + ASSERT_TRUE(prev.has_value()); + EXPECT_EQ(existing, prev->value); + + cache.disconnect(); +} + +// Test 4: putIfAbsent after disconnect should throw +TEST(PutIfAbsentIntegrationTest, PutIfAbsentAfterDisconnect) { + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + cache.disconnect(); + + ByteArray key = {'t', 'e', 's', 't'}; + ByteArray value = {'v'}; + EXPECT_THROW({ + cache.putIfAbsent(key, value); + }, std::runtime_error); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new InfinispanTestEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/integration/ReplaceIntegrationTest.cpp b/tests/integration/ReplaceIntegrationTest.cpp new file mode 100644 index 0000000..d8bdacc --- /dev/null +++ b/tests/integration/ReplaceIntegrationTest.cpp @@ -0,0 +1,117 @@ +#include +#include +#include "hotrod/RemoteCache.h" +#include "InfinispanTestEnvironment.h" + +using namespace hotrod; +using namespace hotrod::test; + +/** + * REPLACE Integration Tests - No Auth + * + * Replaces only when the key is present. Reference: + * - ROADMAP Step 10: conditional operations + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceOperation + */ + +namespace { + +void createCacheViaCLI(const std::string& cacheName) { + std::string cmd = "docker exec " + InfinispanTestEnvironment::containerID + + " bash -c \"echo 'create cache --template=org.infinispan.DIST_SYNC " + cacheName + + "' | /opt/infinispan/bin/cli.sh -c http://admin:password@localhost:11222\" >/dev/null 2>&1"; + std::ignore = system(cmd.c_str()); +} + +} // anonymous namespace + +// Test 1: key present -> value is replaced +TEST(ReplaceIntegrationTest, ReplacesWhenPresent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', '1'}; + ByteArray value1 = {'o', 'l', 'd'}; + ByteArray value2 = {'n', 'e', 'w'}; + cache.put(key, value1).get(); + + cache.replace(key, value2).get(); + + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(value2, after.value()); + + cache.disconnect(); +} + +// Test 2: key absent -> nothing stored +TEST(ReplaceIntegrationTest, DoesNotStoreWhenAbsent) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', 'n', 'o', 'p', 'e'}; + ByteArray value = {'x'}; + cache.remove(key).get(); // ensure absent + + auto prev = cache.replace(key, value).get(); + EXPECT_FALSE(prev.has_value()); + + auto after = cache.get(key).get(); + EXPECT_FALSE(after.has_value()); // must not have been created + + cache.disconnect(); +} + +// Test 3: key present + previousValue -> returns the replaced entry +TEST(ReplaceIntegrationTest, ReturnsPreviousWhenReplaced) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', '3'}; + ByteArray value1 = {'o', 'l', 'd'}; + ByteArray value2 = {'n', 'e', 'w'}; + cache.put(key, value1).get(); + + auto prev = cache.replace(key, value2, 0, 0, true).get(); + ASSERT_TRUE(prev.has_value()); + EXPECT_EQ(value1, prev->value); + + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(value2, after.value()); + + cache.disconnect(); +} + +// Test 4: replace after disconnect should throw +TEST(ReplaceIntegrationTest, ReplaceAfterDisconnect) { + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + cache.disconnect(); + + ByteArray key = {'t', 'e', 's', 't'}; + ByteArray value = {'v'}; + EXPECT_THROW({ + cache.replace(key, value); + }, std::runtime_error); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new InfinispanTestEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/unit/ContainsKeyTest.cpp b/tests/unit/ContainsKeyTest.cpp new file mode 100644 index 0000000..12ad810 --- /dev/null +++ b/tests/unit/ContainsKeyTest.cpp @@ -0,0 +1,74 @@ +#include +#include "hotrod/Codec.h" +#include "hotrod/HeaderCodec.h" +#include +#include + +using namespace hotrod; + +/** + * CONTAINS_KEY Unit Tests + * + * Request format (opcode 0x0F) - key only, like GET: + * - Protocol 4.0 header (11+ bytes) + * - Key (lp_bytes) + * + * Response (0x10) carries no body — only the status. Java ContainsKeyOperation + * returns !isNotExist(status) && isSuccess(status). + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ContainsKeyOperation + */ + +namespace { + +RequestHeader makeHeader() { + RequestHeader h; + h.messageId = 1; + h.version = 0x28; // Protocol 4.0 + h.opcode = 0x0F; // CONTAINS_KEY_REQUEST + h.cacheName = ""; + h.flags = 0; + h.clientIntelligence = ClientIntelligence::BASIC; + h.topologyId = 0; + h.keyMediaType = 0; + h.valueMediaType = 0; + return h; +} + +} // namespace + +// Test 1: request is header + key only (no expiration, no value) +TEST(ContainsKeyTest, EncodeKeyOnly) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'m', 'y', 'k', 'e', 'y'}; + Codec::writeByteArray(request, key); + + EXPECT_EQ(0xA0, request[0]); + EXPECT_EQ(0x0F, request[3]); // opcode + + size_t off = 11; + EXPECT_EQ(key, Codec::readByteArray(request, off)); + EXPECT_EQ(off, request.size()); // nothing trailing the key +} + +// Test 2: empty key still encodes as a zero-length lp_bytes +TEST(ContainsKeyTest, EncodeEmptyKey) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {}; + Codec::writeByteArray(request, key); + + size_t off = 11; + EXPECT_EQ(key, Codec::readByteArray(request, off)); + EXPECT_EQ(off, request.size()); +} + +// Test 3: opcode constants +TEST(ContainsKeyTest, OpcodeConstants) { + EXPECT_EQ(0x0F, Opcode::CONTAINS_KEY_REQUEST); + EXPECT_EQ(0x10, Opcode::CONTAINS_KEY_RESPONSE); +} diff --git a/tests/unit/PutIfAbsentTest.cpp b/tests/unit/PutIfAbsentTest.cpp new file mode 100644 index 0000000..be16ad0 --- /dev/null +++ b/tests/unit/PutIfAbsentTest.cpp @@ -0,0 +1,115 @@ +#include +#include "hotrod/Codec.h" +#include "hotrod/HeaderCodec.h" +#include +#include + +using namespace hotrod; + +/** + * PUT_IF_ABSENT Unit Tests + * + * Request format (opcode 0x05) - identical layout to PUT: + * - Protocol 4.0 header (11+ bytes) + * - Key (lp_bytes) + * - time_units (u1): HIGH nibble = lifespan unit, LOW nibble = maxIdle unit + * (0x07 = DEFAULT/infinite). Matches Java TimeUnitParam.encodeTimeUnits. + * - lifespan (vLong) only if lifespan unit != DEFAULT + * - max_idle (vLong) only if maxIdle unit != DEFAULT + * - Value (lp_bytes) + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.PutIfAbsentOperation + * (extends AbstractKeyValueOperation, same encoder as PUT) + */ + +namespace { + +RequestHeader makeHeader() { + RequestHeader h; + h.messageId = 1; + h.version = 0x28; // Protocol 4.0 + h.opcode = 0x05; // PUT_IF_ABSENT_REQUEST + h.cacheName = ""; + h.flags = 0; + h.clientIntelligence = ClientIntelligence::BASIC; + h.topologyId = 0; + h.keyMediaType = 0; + h.valueMediaType = 0; + return h; +} + +} // namespace + +// Test 1: default expiration (lifespan=0, maxIdle=0) -> time_units 0x77, no vLongs +TEST(PutIfAbsentTest, EncodeDefaultExpiration) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k', '1'}; + ByteArray value = {'v', '1', 'v'}; + Codec::writeByteArray(request, key); + request.push_back(0x77); // lifespan default, maxIdle default + Codec::writeByteArray(request, value); + + EXPECT_EQ(0xA0, request[0]); + EXPECT_EQ(0x05, request[3]); // opcode + + size_t off = 11; + EXPECT_EQ(key, Codec::readByteArray(request, off)); + EXPECT_EQ(0x77, request[off++]); // both default -> no vLongs follow + EXPECT_EQ(value, Codec::readByteArray(request, off)); + EXPECT_EQ(off, request.size()); // nothing trailing +} + +// Test 2: lifespan set (high nibble = SECONDS 0x0), maxIdle default (low 0x7) +TEST(PutIfAbsentTest, EncodeLifespanOnly) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + uint8_t timeUnits = (0x00 << 4) | 0x07; // lifespan SECONDS, maxIdle DEFAULT + request.push_back(timeUnits); + Codec::writeVLong(request, 30); // lifespan present + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + uint8_t tu = request[off++]; + EXPECT_EQ(0x07, tu); + EXPECT_EQ(0x00, (tu >> 4) & 0x0F); // lifespan unit present + EXPECT_EQ(0x07, tu & 0x0F); // maxIdle default + EXPECT_EQ(30u, Codec::readVLong(request, off)); + EXPECT_EQ(value, Codec::readByteArray(request, off)); +} + +// Test 3: both lifespan and maxIdle set -> two vLongs, lifespan first +TEST(PutIfAbsentTest, EncodeBothExpirations) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + request.push_back((0x00 << 4) | 0x00); // both SECONDS + Codec::writeVLong(request, 30); // lifespan + Codec::writeVLong(request, 45); // maxIdle + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + EXPECT_EQ(0x00, request[off++]); // time_units + EXPECT_EQ(30u, Codec::readVLong(request, off)); // lifespan first + EXPECT_EQ(45u, Codec::readVLong(request, off)); // maxIdle second + EXPECT_EQ(value, Codec::readByteArray(request, off)); +} + +// Test 4: opcode constants +TEST(PutIfAbsentTest, OpcodeConstants) { + EXPECT_EQ(0x05, Opcode::PUT_IF_ABSENT_REQUEST); + EXPECT_EQ(0x06, Opcode::PUT_IF_ABSENT_RESPONSE); +} diff --git a/tests/unit/ReplaceTest.cpp b/tests/unit/ReplaceTest.cpp new file mode 100644 index 0000000..7c12168 --- /dev/null +++ b/tests/unit/ReplaceTest.cpp @@ -0,0 +1,115 @@ +#include +#include "hotrod/Codec.h" +#include "hotrod/HeaderCodec.h" +#include +#include + +using namespace hotrod; + +/** + * REPLACE Unit Tests + * + * Request format (opcode 0x07) - identical layout to PUT: + * - Protocol 4.0 header (11+ bytes) + * - Key (lp_bytes) + * - time_units (u1): HIGH nibble = lifespan unit, LOW nibble = maxIdle unit + * (0x07 = DEFAULT/infinite). Matches Java TimeUnitParam.encodeTimeUnits. + * - lifespan (vLong) only if lifespan unit != DEFAULT + * - max_idle (vLong) only if maxIdle unit != DEFAULT + * - Value (lp_bytes) + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceOperation + * (extends AbstractKeyValueOperation, same encoder as PUT) + */ + +namespace { + +RequestHeader makeHeader() { + RequestHeader h; + h.messageId = 1; + h.version = 0x28; // Protocol 4.0 + h.opcode = 0x07; // REPLACE_REQUEST + h.cacheName = ""; + h.flags = 0; + h.clientIntelligence = ClientIntelligence::BASIC; + h.topologyId = 0; + h.keyMediaType = 0; + h.valueMediaType = 0; + return h; +} + +} // namespace + +// Test 1: default expiration (lifespan=0, maxIdle=0) -> time_units 0x77, no vLongs +TEST(ReplaceTest, EncodeDefaultExpiration) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k', '1'}; + ByteArray value = {'v', '1', 'v'}; + Codec::writeByteArray(request, key); + request.push_back(0x77); // lifespan default, maxIdle default + Codec::writeByteArray(request, value); + + EXPECT_EQ(0xA0, request[0]); + EXPECT_EQ(0x07, request[3]); // opcode + + size_t off = 11; + EXPECT_EQ(key, Codec::readByteArray(request, off)); + EXPECT_EQ(0x77, request[off++]); // both default -> no vLongs follow + EXPECT_EQ(value, Codec::readByteArray(request, off)); + EXPECT_EQ(off, request.size()); // nothing trailing +} + +// Test 2: maxIdle set (low nibble = SECONDS 0x0), lifespan default (high 0x7) +TEST(ReplaceTest, EncodeMaxIdleOnly) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + uint8_t timeUnits = (0x07 << 4) | 0x00; // lifespan DEFAULT, maxIdle SECONDS + request.push_back(timeUnits); + Codec::writeVLong(request, 45); // maxIdle present + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + uint8_t tu = request[off++]; + EXPECT_EQ(0x70, tu); + EXPECT_EQ(0x07, (tu >> 4) & 0x0F); // lifespan default + EXPECT_EQ(0x00, tu & 0x0F); // maxIdle unit present + EXPECT_EQ(45u, Codec::readVLong(request, off)); + EXPECT_EQ(value, Codec::readByteArray(request, off)); +} + +// Test 3: both lifespan and maxIdle set -> two vLongs, lifespan first +TEST(ReplaceTest, EncodeBothExpirations) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + request.push_back((0x00 << 4) | 0x00); // both SECONDS + Codec::writeVLong(request, 30); // lifespan + Codec::writeVLong(request, 45); // maxIdle + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + EXPECT_EQ(0x00, request[off++]); // time_units + EXPECT_EQ(30u, Codec::readVLong(request, off)); // lifespan first + EXPECT_EQ(45u, Codec::readVLong(request, off)); // maxIdle second + EXPECT_EQ(value, Codec::readByteArray(request, off)); +} + +// Test 4: opcode constants +TEST(ReplaceTest, OpcodeConstants) { + EXPECT_EQ(0x07, Opcode::REPLACE_REQUEST); + EXPECT_EQ(0x08, Opcode::REPLACE_RESPONSE); +}