apply(TurnContext context) {
return chooseMoves(context);
diff --git a/src/main/java/com/fortemate/dicechess/bot/package-info.java b/src/main/java/com/fortemate/dicechess/bot/package-info.java
index 40166f2..5c782fa 100644
--- a/src/main/java/com/fortemate/dicechess/bot/package-info.java
+++ b/src/main/java/com/fortemate/dicechess/bot/package-info.java
@@ -11,7 +11,7 @@
* {@link com.fortemate.dicechess.bot.Main}: Application entry point. Configures port, loads secrets from env,
* instantiates evaluator and strategy, and launches the HTTP webhook server.
* {@link com.fortemate.dicechess.bot.Strategy}: Core functional interface for decision-making logic.
- * Maps a {@link lv.id.jc.dicechess.runtime.TurnContext} to a list of long algebraic move notations.
+ * Maps a {@link com.fortemate.dicechess.runtime.TurnContext} to a list of long algebraic move notations.
* {@link com.fortemate.dicechess.bot.OnnxStrategy}: Primary strategy implementation. Parses DFEN via
* {@link dicechess.engine.jvmapi.JvmApi}, expands full multi-move turns via {@link dicechess.engine.jvmapi.JvmApi#legalTurns},
* and scores candidate positions using {@link com.fortemate.dicechess.bot.OnnxEvaluator}.
@@ -25,11 +25,12 @@
* Environment Configuration
* | Variable | Default | Description |
* | {@code DICECHESS_WEBHOOK_SECRET} | Empty | HMAC secret key for verifying incoming webhook requests. |
+ * | {@code DICECHESS_WEBHOOK_NEXT_SECRET} | Empty | Pending HMAC secret key for dual-key rotation or verification. |
* | {@code MODEL_PATH} | {@code models/baseline.onnx} | Path to the ONNX model file on disk. |
* | {@code PORT} | {@code 8080} | HTTP server binding port for incoming webhook deliveries. |
*
*
- * @see lv.id.jc.dicechess.runtime.CustomHandlerServer
- * @see lv.id.jc.dicechess.runtime.WebhookHandler
+ * @see com.fortemate.dicechess.runtime.CustomHandlerServer
+ * @see com.fortemate.dicechess.runtime.WebhookHandler
*/
package com.fortemate.dicechess.bot;
diff --git a/src/test/java/com/fortemate/dicechess/bot/MainTest.java b/src/test/java/com/fortemate/dicechess/bot/MainTest.java
new file mode 100644
index 0000000..552904e
--- /dev/null
+++ b/src/test/java/com/fortemate/dicechess/bot/MainTest.java
@@ -0,0 +1,102 @@
+package com.fortemate.dicechess.bot;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MainTest {
+
+ @Test
+ void testResolvePort() {
+ assertEquals(8080, Main.resolvePort(null));
+ assertEquals(8080, Main.resolvePort(""));
+ assertEquals(8080, Main.resolvePort(" "));
+ assertEquals(9090, Main.resolvePort("9090"));
+ assertEquals(8080, Main.resolvePort("not-a-number"));
+ }
+
+ @Test
+ void testResolveWebhookKeysWithActiveOnly() {
+ var keys = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", "active-key"));
+ assertTrue(keys.isPresent());
+ assertEquals("active-key", keys.get().active());
+ assertNull(keys.get().pending());
+ }
+
+ @Test
+ void testResolveWebhookKeysWithPendingOnly() {
+ var keys = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_NEXT_SECRET", "pending-key"));
+ assertTrue(keys.isPresent());
+ assertNull(keys.get().active());
+ assertEquals("pending-key", keys.get().pending());
+ }
+
+ @Test
+ void testResolveWebhookKeysWithBothKeys() {
+ var keys = Main.resolveWebhookKeys(Map.of(
+ "DICECHESS_WEBHOOK_SECRET", "active-key",
+ "DICECHESS_WEBHOOK_NEXT_SECRET", "pending-key"
+ ));
+ assertTrue(keys.isPresent());
+ assertEquals("active-key", keys.get().active());
+ assertEquals("pending-key", keys.get().pending());
+ }
+
+ @Test
+ void testResolveWebhookKeysFailsClosedWhenMissingOrBlank() {
+ assertTrue(Main.resolveWebhookKeys(Map.of()).isEmpty());
+ assertTrue(Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", " ")).isEmpty());
+ }
+
+ @Test
+ void testResolveWebhookKeysFromSystemEnvironmentDoesNotThrow() {
+ org.junit.jupiter.api.function.ThrowingSupplier> supplier = Main::resolveWebhookKeys;
+ assertDoesNotThrow(supplier);
+ }
+
+ @Test
+ void testStartApplicationFailsClosedWithoutKeys() {
+ var server = Main.startApplication(Map.of());
+ assertNull(server, "startApplication must return null when webhook keys are absent");
+ }
+
+ @Test
+ void testStartApplicationSuccessWithValidKeys() throws Exception {
+ var server = Main.startApplication(Map.of(
+ "DICECHESS_WEBHOOK_SECRET", "test-secret",
+ "PORT", "0"
+ ));
+ assertNotNull(server, "startApplication must return running server when keys are provided");
+ try {
+ var port = server.getAddress().getPort();
+ try (var client = HttpClient.newHttpClient()) {
+ var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/health")).GET().build();
+ var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, resp.statusCode());
+ assertEquals("OK", resp.body());
+ }
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testStartApplicationFailsGracefullyOnInvalidPort() {
+ var server = Main.startApplication(Map.of(
+ "DICECHESS_WEBHOOK_SECRET", "test-secret",
+ "PORT", "-1"
+ ));
+ assertNull(server, "startApplication should return null on port bind failure");
+ }
+
+ @Test
+ void testMainMethodExecutesCleanlyWhenUnconfigured() {
+ assertDoesNotThrow(() -> Main.main(new String[0]));
+ }
+}
diff --git a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java
index 24408d6..4f2277e 100644
--- a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java
+++ b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java
@@ -1,6 +1,6 @@
package com.fortemate.dicechess.bot;
-import lv.id.jc.dicechess.runtime.TurnContext;
+import com.fortemate.dicechess.runtime.TurnContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -28,17 +28,23 @@ void tearDown() {
}
@Test
- void testChooseMovesWithEmptyDfen() {
- var context = new TurnContext("test-game", "", null, List.of());
+ void testChooseMovesWithNullContext() {
+ var moves = strategy.chooseMoves(null);
+ assertTrue(moves.isEmpty(), "Should return empty list for null context");
+ }
+
+ @Test
+ void testChooseMovesWithInvalidDfen() {
+ var context = new TurnContext("test-game", "White", 1L, "invalid-dfen-string", null, List.of(), false);
var moves = strategy.chooseMoves(context);
- assertTrue(moves.isEmpty(), "Should return empty list for empty DFEN");
+ assertTrue(moves.isEmpty(), "Should return empty list for invalid DFEN");
}
@Test
void testChooseMovesWithInitialPosition() {
// Initial DFEN position with dice pool 'p' (pawn roll) for white
var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p";
- var context = new TurnContext("test-game", dfen, null, List.of());
+ var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false);
var moves = strategy.chooseMoves(context);
assertFalse(moves.isEmpty(), "Should generate at least one legal move for pawn roll");
@@ -49,10 +55,35 @@ void testChooseMovesWithInitialPosition() {
void testChooseMovesWithTripleDicePool() {
// Initial DFEN position with dice pool 'pnb' for white
var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 pnb";
- var context = new TurnContext("test-game", dfen, null, List.of());
+ var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false);
var moves = strategy.chooseMoves(context);
assertFalse(moves.isEmpty(), "Should generate legal turn sequence for triple dice pool");
assertTrue(moves.size() <= 3, "Turn should contain at most 3 micro-moves");
}
+
+ @Test
+ void testOnTurnReturnsTurnAction() {
+ var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p";
+ var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false);
+
+ var action = strategy.onTurn(context);
+ assertNotNull(action, "onTurn should return non-null TurnAction");
+ assertFalse(action.moves().isEmpty(), "onTurn moves should not be empty");
+ assertFalse(action.offerDraw(), "onTurn offerDraw should default to false");
+ }
+
+ @Test
+ void testDefaultDrawAndDoubleDecisions() {
+ assertFalse(strategy.onDrawDecision(null).acceptDraw(), "Should decline draw by default");
+ assertFalse(strategy.onDoubleOpportunity(null).offerDouble(), "Should roll without offering double by default");
+ assertFalse(strategy.onDoubleDecision(null).acceptDouble(), "Should decline double by default");
+ }
+
+ @Test
+ void testStrategyApplyMatchesChooseMoves() {
+ var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p";
+ var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false);
+ assertEquals(strategy.chooseMoves(context), strategy.apply(context));
+ }
}
diff --git a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java
index 857c4ab..d51803f 100644
--- a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java
+++ b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java
@@ -1,8 +1,8 @@
package com.fortemate.dicechess.bot;
+import com.fortemate.dicechess.runtime.Signatures;
+import com.fortemate.dicechess.runtime.WebhookHandler;
import com.sun.net.httpserver.HttpServer;
-import lv.id.jc.dicechess.runtime.CustomHandlerServer;
-import lv.id.jc.dicechess.runtime.WebhookHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -12,22 +12,25 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class WebhookIntegrationTest {
+ private static final String SECRET = "test-secret";
+
private HttpServer server;
private OnnxEvaluator evaluator;
+ private HttpClient client;
@BeforeEach
void setUp() throws IOException {
+ client = HttpClient.newHttpClient();
evaluator = new OnnxEvaluator(null);
var strategy = new OnnxStrategy(evaluator);
- var handler = new WebhookHandler("test-secret", strategy);
-
- // Bind on ephemeral port 0
- server = CustomHandlerServer.start(0, "/api/webhook", handler);
+ server = Main.start(0, SECRET, strategy);
}
@AfterEach
@@ -38,12 +41,14 @@ void tearDown() {
if (evaluator != null) {
evaluator.close();
}
+ if (client != null) {
+ client.close();
+ }
}
@Test
void testWebhookRejectsUnauthenticatedRequest() throws Exception {
var port = server.getAddress().getPort();
- var client = HttpClient.newHttpClient();
// A bare GET without proper HMAC signature headers should be rejected
var request = HttpRequest.newBuilder()
@@ -54,4 +59,86 @@ void testWebhookRejectsUnauthenticatedRequest() throws Exception {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(400, response.statusCode(), "Unauthenticated request should return 400");
}
+
+ @Test
+ void testVerificationHandshake() throws Exception {
+ var port = server.getAddress().getPort();
+
+ var body = "{\"type\":\"verification\",\"nonce\":\"test-nonce-123\"}";
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/webhook"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ var response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, response.statusCode(), "Handshake should succeed with 200");
+ assertTrue(response.body().contains("test-nonce-123"), "Response should echo the nonce");
+ }
+
+ @Test
+ void testSignedYourTurnDelivery() throws Exception {
+ var port = server.getAddress().getPort();
+
+ var body = """
+ {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}}
+ """.strip();
+ var now = Instant.now().getEpochSecond();
+ var signature = Signatures.sign(SECRET, now, body);
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/webhook"))
+ .header("Content-Type", "application/json")
+ .header(WebhookHandler.TIMESTAMP_HEADER, String.valueOf(now))
+ .header(WebhookHandler.SIGNATURE_HEADER, signature)
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ var response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, response.statusCode(), "Signed delivery should succeed with 200");
+ assertTrue(response.body().contains("\"moves\":["), "Response should contain moves");
+ assertTrue(response.body().contains("\"offerDraw\":false"), "Response should specify offerDraw");
+ }
+
+ @Test
+ void testSignedDeliveryWithInvalidSignature() throws Exception {
+ var port = server.getAddress().getPort();
+
+ var body = """
+ {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}}
+ """.strip();
+ var now = Instant.now().getEpochSecond();
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/webhook"))
+ .header("Content-Type", "application/json")
+ .header(WebhookHandler.TIMESTAMP_HEADER, String.valueOf(now))
+ .header(WebhookHandler.SIGNATURE_HEADER, "invalid-signature")
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ var response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ assertEquals(401, response.statusCode(), "Bad signature should return 401");
+ }
+
+ @Test
+ void testHealthCheckAndRootEndpoints() throws Exception {
+ var port = server.getAddress().getPort();
+
+ var healthReq = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/health"))
+ .GET()
+ .build();
+ var healthResp = client.send(healthReq, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, healthResp.statusCode());
+ assertEquals("OK", healthResp.body());
+
+ var rootReq = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/"))
+ .GET()
+ .build();
+ var rootResp = client.send(rootReq, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, rootResp.statusCode());
+ assertEquals("OK", rootResp.body());
+ }
}