Date: Fri, 1 May 2026 10:56:21 -0600
Subject: [PATCH 11/15] test: rename and correct metrics wiring sanity checks
(not #94 classpath regression)
---
...t.java => TurnstileMetricsWiringTest.java} | 34 +++++++------------
1 file changed, 13 insertions(+), 21 deletions(-)
rename src/test/java/com/digitalsanctuary/cf/test/turnstile/{TurnstileWithoutActuatorTest.java => TurnstileMetricsWiringTest.java} (68%)
diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
similarity index 68%
rename from src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java
rename to src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
index 87f0081..e83bd4a 100644
--- a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java
+++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
@@ -1,20 +1,24 @@
package com.digitalsanctuary.cf.test.turnstile;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ActiveProfiles;
import com.digitalsanctuary.cf.test.TestApplication;
+import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics;
import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService;
/**
- * Regression tests for GitHub issue #94.
- * Verifies the library starts without NoClassDefFoundError when Micrometer or Actuator is absent.
+ * Metrics wiring sanity checks that verify auto-configuration exclusions work correctly.
+ *
+ * Note: Micrometer is still on the test classpath (via spring-boot-starter-actuator), so these
+ * tests cannot detect a bytecode-level {@code NoClassDefFoundError} regression. They verify that
+ * Spring auto-configuration exclusions are respected and that the correct metrics implementation
+ * is active given the classpath conditions.
*/
@SpringBootTest(
classes = TestApplication.class,
@@ -25,7 +29,7 @@
}
)
@ActiveProfiles("test")
-class TurnstileWithoutActuatorTest {
+class TurnstileMetricsWiringTest {
@Autowired
private TurnstileValidationService turnstileValidationService;
@@ -33,27 +37,16 @@ class TurnstileWithoutActuatorTest {
@Autowired
private TurnstileMetrics turnstileMetrics;
- @Autowired
- private ApplicationContext applicationContext;
-
- /**
- * Verifies TurnstileValidationService is available when Micrometer auto-configs are excluded.
- */
- @Test
- void contextLoadsWithoutMicrometer() {
- assertNotNull(turnstileValidationService, "TurnstileValidationService should be available");
- }
-
/**
- * Verifies a TurnstileMetrics bean is available when Micrometer auto-configs are excluded.
- * Regression test for issue #94: unconditionally-loaded classes with direct Micrometer type
- * references caused NoClassDefFoundError when Micrometer was absent. The core fix ensures
- * that a metrics bean (of any implementation) is always available without throwing errors.
+ * Verifies that a TurnstileMetrics bean is available when metrics auto-configs are excluded.
+ * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active.
*/
@Test
void metricsAvailableWhenMicrometerAutoConfigExcluded() {
assertNotNull(turnstileMetrics,
"A TurnstileMetrics bean should be available even with Micrometer auto-config excluded");
+ assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics,
+ "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath");
}
/**
@@ -73,7 +66,6 @@ void internalCountersWorkWithoutMicrometer() {
long before = turnstileValidationService.getValidationCount();
turnstileValidationService.validateTurnstileResponse("short");
long after = turnstileValidationService.getValidationCount();
- assertNotNull(after, "Validation count should be trackable");
assertTrue(after > before, "Validation count should have incremented");
}
}
From 8974419f0963d9d0cc72fd49f3f7156dac886bf5 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Fri, 1 May 2026 10:59:37 -0600
Subject: [PATCH 12/15] fix: resolve PMD and Checkstyle violations in new
metrics classes
---
.../metrics/MicrometerTurnstileMetrics.java | 2 +-
.../metrics/NoOpTurnstileMetrics.java | 12 +-
.../service/TurnstileValidationService.java | 113 ++++++++++++++----
3 files changed, 99 insertions(+), 28 deletions(-)
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
index 5de0234..279dc9e 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
@@ -61,7 +61,7 @@ public void recordError(ValidationResultType type) {
case CONFIGURATION_ERROR -> configErrorCounter.increment();
case INVALID_TOKEN -> validationErrorCounter.increment();
case INPUT_ERROR -> inputErrorCounter.increment();
- default -> {}
+ default -> { }
}
}
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java
index 31152c2..3cf446a 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java
@@ -8,14 +8,18 @@
public class NoOpTurnstileMetrics implements TurnstileMetrics {
@Override
- public void recordValidation() {}
+ public void recordValidation() { // no-op
+ }
@Override
- public void recordSuccess() {}
+ public void recordSuccess() { // no-op
+ }
@Override
- public void recordError(ValidationResultType type) {}
+ public void recordError(ValidationResultType type) { // no-op
+ }
@Override
- public void recordResponseTime(long milliseconds) {}
+ public void recordResponseTime(long milliseconds) { // no-op
+ }
}
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
index ac12379..e59c937 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
@@ -255,54 +255,121 @@ private void recordError(ValidationResultType resultType) {
case CONFIGURATION_ERROR -> configErrorCount.increment();
case INVALID_TOKEN -> validationErrorCount.increment();
case INPUT_ERROR -> inputErrorCount.increment();
- default -> {}
+ default -> { }
}
}
- /** @return total number of validation attempts */
- public long getValidationCount() { return validationCount.sum(); }
+ /**
+ * Gets the total number of validation attempts.
+ *
+ * @return total number of validation attempts
+ */
+ public long getValidationCount() {
+ return validationCount.sum();
+ }
- /** @return number of successful validations */
- public long getSuccessCount() { return successCount.sum(); }
+ /**
+ * Gets the number of successful validations.
+ *
+ * @return number of successful validations
+ */
+ public long getSuccessCount() {
+ return successCount.sum();
+ }
- /** @return number of failed validations */
- public long getErrorCount() { return errorCount.sum(); }
+ /**
+ * Gets the number of failed validations.
+ *
+ * @return number of failed validations
+ */
+ public long getErrorCount() {
+ return errorCount.sum();
+ }
- /** @return number of network errors */
- public long getNetworkErrorCount() { return networkErrorCount.sum(); }
+ /**
+ * Gets the number of network errors.
+ *
+ * @return number of network errors
+ */
+ public long getNetworkErrorCount() {
+ return networkErrorCount.sum();
+ }
- /** @return number of configuration errors */
- public long getConfigErrorCount() { return configErrorCount.sum(); }
+ /**
+ * Gets the number of configuration errors.
+ *
+ * @return number of configuration errors
+ */
+ public long getConfigErrorCount() {
+ return configErrorCount.sum();
+ }
- /** @return number of validation errors (invalid tokens) */
- public long getValidationErrorCount() { return validationErrorCount.sum(); }
+ /**
+ * Gets the number of validation errors (invalid tokens).
+ *
+ * @return number of validation errors
+ */
+ public long getValidationErrorCount() {
+ return validationErrorCount.sum();
+ }
- /** @return number of input validation errors */
- public long getInputErrorCount() { return inputErrorCount.sum(); }
+ /**
+ * Gets the number of input validation errors.
+ *
+ * @return number of input validation errors
+ */
+ public long getInputErrorCount() {
+ return inputErrorCount.sum();
+ }
- /** @return time of last response in milliseconds */
- public long getLastResponseTime() { return lastResponseTime.get(); }
+ /**
+ * Gets the time of the last response in milliseconds.
+ *
+ * @return time of last response in milliseconds
+ */
+ public long getLastResponseTime() {
+ return lastResponseTime.get();
+ }
- /** @return average response time in milliseconds, or 0 if no responses yet */
+ /**
+ * Gets the average response time in milliseconds.
+ *
+ * @return average response time in milliseconds, or 0 if no responses yet
+ */
public double getAverageResponseTime() {
long count = responseCount.get();
return count > 0 ? (double) totalResponseTime.get() / count : 0;
}
- /** @return error rate as a percentage (0-100), or 0 if no attempts yet */
+ /**
+ * Gets the error rate as a percentage of total validation attempts.
+ *
+ * @return error rate as a percentage (0-100), or 0 if no attempts yet
+ */
public double getErrorRate() {
long total = validationCount.sum();
return total > 0 ? (double) errorCount.sum() * 100 / total : 0;
}
/**
- * @deprecated Use {@link #getTurnstileSitekey()} instead.
+ * Gets the Turnstile Sitekey.
+ *
+ * @return the Turnstile Sitekey.
+ * @deprecated Use {@link #getTurnstileSitekey()} instead. Will be removed in a future version.
*/
@Deprecated
- public String getTurnsiteSitekey() { return getTurnstileSitekey(); }
+ public String getTurnsiteSitekey() {
+ return getTurnstileSitekey();
+ }
- /** @return the Turnstile Sitekey */
- public String getTurnstileSitekey() { return properties.getSitekey(); }
+ /**
+ * Gets the Turnstile Sitekey.
+ *
+ * @return the Turnstile Sitekey
+ */
+ public String getTurnstileSitekey() {
+ return properties.getSitekey();
+ }
/**
* Gets the client IP address from the ServletRequest.
From 62a885530760e1204b182abb0829de00282eb236 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Fri, 1 May 2026 11:32:02 -0600
Subject: [PATCH 13/15] docs: add implementation plan for issue #94 fix
---
.../2026-05-01-optional-micrometer-fix.md | 1003 +++++++++++++++++
1 file changed, 1003 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md
diff --git a/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md b/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md
new file mode 100644
index 0000000..d5e6bbb
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md
@@ -0,0 +1,1003 @@
+# Optional Micrometer Dependency Fix Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix `NoClassDefFoundError: io/micrometer/core/instrument/MeterRegistry` when Micrometer is absent by isolating all Micrometer references behind classes that are only loaded when Micrometer is on the classpath.
+
+**Architecture:** Introduce a `TurnstileMetrics` interface with a no-op default and a Micrometer-backed implementation. Classes that are always loaded (`TurnstileValidationService`, `TurnstileServiceConfig`, `TurnstileConfiguration`) are purged of all direct Micrometer type references. The Micrometer implementation lives in a class only loaded when `@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")` is true.
+
+**Tech Stack:** Java 17, Spring Boot 4.x auto-configuration, Micrometer (optional), Lombok, JUnit 5, Gradle
+
+---
+
+## File Map
+
+**New files:**
+- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java` — interface; no Micrometer imports
+- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java` — active when Micrometer absent; no Micrometer imports
+- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java` — active when Micrometer present; contains all Micrometer imports/references
+- `src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java` — Spring context test that excludes MeterRegistry to verify no-actuator loading
+
+**Modified files:**
+- `src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java` — remove `Optional`, `Counter`, `Timer` fields; take `TurnstileMetrics` instead
+- `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java` — remove `ObjectProvider`; add `@ConditionalOnMissingBean` no-op provider; wire `TurnstileMetrics` into service
+- `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java` — add bean that registers `MicrometerTurnstileMetrics` into Spring context
+- `src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java` — switch `@ConditionalOnClass(MeterRegistry.class)` to string form; remove Micrometer import
+
+---
+
+## Task 1: Create `TurnstileMetrics` interface
+
+**Files:**
+- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java`
+
+- [ ] **Step 1: Create the interface**
+
+```java
+package com.digitalsanctuary.cf.turnstile.metrics;
+
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
+
+/**
+ * Abstraction for recording Turnstile validation metrics.
+ * Implementations may be no-op (when Micrometer is absent) or Micrometer-backed.
+ */
+public interface TurnstileMetrics {
+ void recordValidation();
+ void recordSuccess();
+ void recordError(ValidationResultType type);
+ void recordResponseTime(long milliseconds);
+}
+```
+
+- [ ] **Step 2: Compile to verify no issues**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java
+git commit -m "feat: add TurnstileMetrics interface for optional Micrometer support"
+```
+
+---
+
+## Task 2: Create `NoOpTurnstileMetrics`
+
+**Files:**
+- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java`
+
+- [ ] **Step 1: Create the no-op implementation**
+
+```java
+package com.digitalsanctuary.cf.turnstile.metrics;
+
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
+
+/**
+ * No-op implementation of TurnstileMetrics used when Micrometer is not on the classpath.
+ */
+public class NoOpTurnstileMetrics implements TurnstileMetrics {
+
+ @Override
+ public void recordValidation() {}
+
+ @Override
+ public void recordSuccess() {}
+
+ @Override
+ public void recordError(ValidationResultType type) {}
+
+ @Override
+ public void recordResponseTime(long milliseconds) {}
+}
+```
+
+- [ ] **Step 2: Compile to verify**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java
+git commit -m "feat: add NoOpTurnstileMetrics for when Micrometer is absent"
+```
+
+---
+
+## Task 3: Create `MicrometerTurnstileMetrics`
+
+This class contains ALL Micrometer imports for the library. It must never be referenced as a type in any class that loads unconditionally.
+
+**Files:**
+- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java`
+
+- [ ] **Step 1: Create the Micrometer implementation**
+
+```java
+package com.digitalsanctuary.cf.turnstile.metrics;
+
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import java.util.concurrent.TimeUnit;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Micrometer-backed implementation of TurnstileMetrics.
+ * This class is only instantiated when Micrometer is present on the classpath.
+ */
+@Slf4j
+public class MicrometerTurnstileMetrics implements TurnstileMetrics {
+
+ private final Counter validationCounter;
+ private final Counter successCounter;
+ private final Counter errorCounter;
+ private final Counter networkErrorCounter;
+ private final Counter configErrorCounter;
+ private final Counter validationErrorCounter;
+ private final Counter inputErrorCounter;
+ private final Timer responseTimer;
+
+ public MicrometerTurnstileMetrics(MeterRegistry registry) {
+ log.info("Initializing Turnstile metrics with MeterRegistry");
+ validationCounter = Counter.builder("turnstile.validation.requests")
+ .description("Total number of Turnstile validation requests").register(registry);
+ successCounter = Counter.builder("turnstile.validation.success")
+ .description("Number of successful Turnstile validations").register(registry);
+ errorCounter = Counter.builder("turnstile.validation.errors")
+ .description("Number of failed Turnstile validations").register(registry);
+ networkErrorCounter = Counter.builder("turnstile.validation.errors.network")
+ .description("Number of Turnstile validation network errors").register(registry);
+ configErrorCounter = Counter.builder("turnstile.validation.errors.config")
+ .description("Number of Turnstile validation configuration errors").register(registry);
+ validationErrorCounter = Counter.builder("turnstile.validation.errors.token")
+ .description("Number of Turnstile validation token errors").register(registry);
+ inputErrorCounter = Counter.builder("turnstile.validation.errors.input")
+ .description("Number of Turnstile validation input errors").register(registry);
+ responseTimer = Timer.builder("turnstile.validation.response.time")
+ .description("Response time for Turnstile validation requests").register(registry);
+ }
+
+ @Override
+ public void recordValidation() {
+ validationCounter.increment();
+ }
+
+ @Override
+ public void recordSuccess() {
+ successCounter.increment();
+ }
+
+ @Override
+ public void recordError(ValidationResultType type) {
+ errorCounter.increment();
+ switch (type) {
+ case NETWORK_ERROR -> networkErrorCounter.increment();
+ case CONFIGURATION_ERROR -> configErrorCounter.increment();
+ case INVALID_TOKEN -> validationErrorCounter.increment();
+ case INPUT_ERROR -> inputErrorCounter.increment();
+ default -> {}
+ }
+ }
+
+ @Override
+ public void recordResponseTime(long milliseconds) {
+ responseTimer.record(milliseconds, TimeUnit.MILLISECONDS);
+ }
+}
+```
+
+- [ ] **Step 2: Compile to verify**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
+git commit -m "feat: add MicrometerTurnstileMetrics implementation"
+```
+
+---
+
+## Task 4: Refactor `TurnstileValidationService` to use `TurnstileMetrics`
+
+Remove all Micrometer imports and type references. Replace `Optional` + individual counter/timer fields with a single `TurnstileMetrics metrics` field.
+
+**Files:**
+- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java`
+
+- [ ] **Step 1: Replace the class with the refactored version**
+
+Replace the entire file content with:
+
+```java
+package com.digitalsanctuary.cf.turnstile.service;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.LongAdder;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.HttpServerErrorException;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestClient;
+import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties;
+import com.digitalsanctuary.cf.turnstile.dto.TurnstileResponse;
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult;
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
+import com.digitalsanctuary.cf.turnstile.exception.TurnstileConfigurationException;
+import com.digitalsanctuary.cf.turnstile.exception.TurnstileNetworkException;
+import com.digitalsanctuary.cf.turnstile.exception.TurnstileValidationException;
+import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Service for validating responses from Cloudflare's Turnstile API.
+ *
+ * This service provides methods to validate Turnstile tokens with the Cloudflare API, handling various error scenarios with appropriate exceptions
+ * and detailed validation results. It also collects metrics on validation attempts, success/failure rates, and response times when metrics are
+ * enabled.
+ *
+ */
+@Slf4j
+@Service
+public class TurnstileValidationService {
+ private static final String UNKNOWN = "unknown";
+ private static final int MIN_TOKEN_LENGTH = 20;
+
+ private final RestClient turnstileRestClient;
+ private final TurnstileConfigProperties properties;
+ private final TurnstileMetrics metrics;
+
+ // Internal counters (always active, independent of Micrometer)
+ private final LongAdder validationCount = new LongAdder();
+ private final LongAdder successCount = new LongAdder();
+ private final LongAdder errorCount = new LongAdder();
+ private final LongAdder networkErrorCount = new LongAdder();
+ private final LongAdder configErrorCount = new LongAdder();
+ private final LongAdder validationErrorCount = new LongAdder();
+ private final LongAdder inputErrorCount = new LongAdder();
+ private final AtomicLong lastResponseTime = new AtomicLong();
+ private final AtomicLong totalResponseTime = new AtomicLong();
+ private final AtomicLong responseCount = new AtomicLong();
+
+ /**
+ * Constructor for TurnstileValidationService.
+ *
+ * @param turnstileRestClient the RestClient to use for making requests to the Turnstile API
+ * @param properties the TurnstileConfigProperties to use for configuration
+ * @param metrics the TurnstileMetrics implementation for recording metrics
+ */
+ public TurnstileValidationService(@Qualifier("turnstileRestClient") RestClient turnstileRestClient,
+ TurnstileConfigProperties properties, TurnstileMetrics metrics) {
+ this.turnstileRestClient = turnstileRestClient;
+ this.properties = properties;
+ this.metrics = metrics;
+ }
+
+ /**
+ * Method called after the bean is initialized. Logs the startup information and validates the required configuration.
+ *
+ * @throws TurnstileConfigurationException if required configuration properties are missing
+ */
+ @PostConstruct
+ public void onStartup() {
+ log.info("TurnstileValidationService started");
+ log.info("Turnstile URL: {}", properties.getUrl());
+ log.info("Turnstile Sitekey: {}", properties.getSitekey());
+ log.info("Turnstile Secret: {}", properties.getSecret() != null && !properties.getSecret().isBlank() ? "[CONFIGURED]" : "[NOT CONFIGURED]");
+ log.info("Turnstile Metrics enabled: {}", properties.getMetrics().isEnabled());
+ log.info("Turnstile Health Check enabled: {}", properties.getMetrics().isHealthCheckEnabled());
+
+ if (properties.getSecret() == null || properties.getSecret().isBlank()) {
+ log.error("Turnstile secret key is not configured. Validation will fail.");
+ }
+ if (properties.getUrl() == null || properties.getUrl().isBlank()) {
+ log.error("Turnstile URL is not configured. Validation will fail.");
+ }
+ }
+
+ /**
+ * Validates the Turnstile response token. Convenience method without remote IP.
+ *
+ * @param token the response token to be validated.
+ * @return true if the response is valid and successful, false otherwise.
+ */
+ public boolean validateTurnstileResponse(String token) {
+ return validateTurnstileResponse(token, null);
+ }
+
+ /**
+ * Validates the Turnstile response token. Returns boolean and handles exceptions internally.
+ *
+ * @param token the response token to be validated.
+ * @param remoteIp the remote IP address of the client (optional).
+ * @return true if the response is valid and successful, false otherwise.
+ */
+ public boolean validateTurnstileResponse(String token, String remoteIp) {
+ try {
+ ValidationResult result = validateTurnstileResponseDetailed(token, remoteIp);
+ return result.isSuccess();
+ } catch (Exception e) {
+ log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Validates the Turnstile response token. Convenience method without remote IP.
+ *
+ * @param token the response token to be validated.
+ * @return a ValidationResult object with detailed information about the validation outcome.
+ * @throws TurnstileConfigurationException if the service is not properly configured
+ * @throws TurnstileNetworkException if a network error occurs during validation
+ * @throws TurnstileValidationException if the token is rejected by Cloudflare
+ */
+ public ValidationResult validateTurnstileResponseDetailed(String token) {
+ return validateTurnstileResponseDetailed(token, null);
+ }
+
+ /**
+ * Validates the Turnstile response token with detailed results and typed exceptions.
+ *
+ * @param token the response token to be validated.
+ * @param remoteIp the remote IP address of the client (optional).
+ * @return a ValidationResult object with detailed information about the validation outcome.
+ * @throws TurnstileConfigurationException if the service is not properly configured
+ * @throws TurnstileNetworkException if a network error occurs during validation
+ * @throws TurnstileValidationException if the token is rejected by Cloudflare
+ */
+ public ValidationResult validateTurnstileResponseDetailed(String token, String remoteIp) {
+ long startTime = System.currentTimeMillis();
+ validationCount.increment();
+ metrics.recordValidation();
+
+ log.trace("Starting validation for token: {} with remoteIp: {}", token, remoteIp);
+
+ if (token == null) {
+ log.warn("Turnstile validation failed: token cannot be null");
+ recordError(ValidationResultType.INPUT_ERROR);
+ return ValidationResult.inputError("Token cannot be null");
+ }
+
+ if (token.isEmpty() || token.isBlank()) {
+ log.warn("Turnstile validation failed: token cannot be empty or blank");
+ recordError(ValidationResultType.INPUT_ERROR);
+ return ValidationResult.inputError("Token cannot be empty or blank");
+ }
+
+ if (token.length() < MIN_TOKEN_LENGTH) {
+ log.warn("Turnstile validation failed: token appears to be too short to be valid (length: {})", token.length());
+ recordError(ValidationResultType.INPUT_ERROR);
+ return ValidationResult.inputError("Token is too short to be valid (length: " + token.length() + ")");
+ }
+
+ String cleanRemoteIp = remoteIp;
+ if (cleanRemoteIp != null && (cleanRemoteIp.isEmpty() || cleanRemoteIp.isBlank())) {
+ log.warn("Turnstile validation: ignoring empty or blank remoteIp");
+ cleanRemoteIp = null;
+ }
+
+ if (properties.getSecret() == null || properties.getSecret().isBlank()) {
+ String msg = "Turnstile secret key is not configured";
+ log.error(msg);
+ recordError(ValidationResultType.CONFIGURATION_ERROR);
+ throw new TurnstileConfigurationException(msg);
+ }
+
+ if (properties.getUrl() == null || properties.getUrl().isBlank()) {
+ String msg = "Turnstile URL is not configured";
+ log.error(msg);
+ recordError(ValidationResultType.CONFIGURATION_ERROR);
+ throw new TurnstileConfigurationException(msg);
+ }
+
+ Map requestBody = new HashMap<>();
+ requestBody.put("secret", properties.getSecret());
+ requestBody.put("response", token);
+ Optional.ofNullable(cleanRemoteIp).ifPresent(ip -> requestBody.put("remoteip", ip));
+
+ log.trace("Making request to Cloudflare Turnstile API at: {}", properties.getUrl());
+
+ try {
+ ValidationResult result = executeValidationRequest(requestBody);
+ long elapsed = System.currentTimeMillis() - startTime;
+ lastResponseTime.set(elapsed);
+ totalResponseTime.addAndGet(elapsed);
+ responseCount.incrementAndGet();
+ metrics.recordResponseTime(elapsed);
+ return result;
+ } catch (HttpClientErrorException e) {
+ log.error("Client error during Turnstile validation: {}", e.getMessage(), e);
+ recordError(ValidationResultType.NETWORK_ERROR);
+ throw new TurnstileNetworkException("Client error: " + e.getMessage(), e);
+ } catch (HttpServerErrorException e) {
+ log.error("Server error during Turnstile validation: {}", e.getMessage(), e);
+ recordError(ValidationResultType.NETWORK_ERROR);
+ throw new TurnstileNetworkException("Server error: " + e.getMessage(), e);
+ } catch (ResourceAccessException e) {
+ log.error("Network error during Turnstile validation: {}", e.getMessage(), e);
+ recordError(ValidationResultType.NETWORK_ERROR);
+ throw new TurnstileNetworkException("Network error: " + e.getMessage(), e);
+ } catch (TurnstileValidationException e) {
+ recordError(ValidationResultType.INVALID_TOKEN);
+ throw e;
+ } catch (Exception e) {
+ log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e);
+ recordError(ValidationResultType.NETWORK_ERROR);
+ throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e);
+ }
+ }
+
+ private ValidationResult executeValidationRequest(Map requestBody) {
+ TurnstileResponse response = turnstileRestClient.post().uri(properties.getUrl())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(requestBody).retrieve().body(TurnstileResponse.class);
+
+ log.debug("Turnstile response: {}", response);
+
+ if (response == null) {
+ log.warn("Turnstile API returned null response");
+ recordError(ValidationResultType.NETWORK_ERROR);
+ return ValidationResult.networkError("Cloudflare returned an empty response");
+ }
+
+ if (response.isSuccess()) {
+ log.debug("Turnstile validation successful");
+ successCount.increment();
+ metrics.recordSuccess();
+ return ValidationResult.success();
+ } else {
+ log.warn("Turnstile validation failed with error codes: {}", response.getErrorCodes());
+ recordError(ValidationResultType.INVALID_TOKEN);
+ throw new TurnstileValidationException("Token validation failed", response.getErrorCodes());
+ }
+ }
+
+ private void recordError(ValidationResultType resultType) {
+ errorCount.increment();
+ metrics.recordError(resultType);
+
+ switch (resultType) {
+ case NETWORK_ERROR -> networkErrorCount.increment();
+ case CONFIGURATION_ERROR -> configErrorCount.increment();
+ case INVALID_TOKEN -> validationErrorCount.increment();
+ case INPUT_ERROR -> inputErrorCount.increment();
+ default -> {}
+ }
+ }
+
+ /** @return total number of validation attempts */
+ public long getValidationCount() { return validationCount.sum(); }
+
+ /** @return number of successful validations */
+ public long getSuccessCount() { return successCount.sum(); }
+
+ /** @return number of failed validations */
+ public long getErrorCount() { return errorCount.sum(); }
+
+ /** @return number of network errors */
+ public long getNetworkErrorCount() { return networkErrorCount.sum(); }
+
+ /** @return number of configuration errors */
+ public long getConfigErrorCount() { return configErrorCount.sum(); }
+
+ /** @return number of validation errors (invalid tokens) */
+ public long getValidationErrorCount() { return validationErrorCount.sum(); }
+
+ /** @return number of input validation errors */
+ public long getInputErrorCount() { return inputErrorCount.sum(); }
+
+ /** @return time of last response in milliseconds */
+ public long getLastResponseTime() { return lastResponseTime.get(); }
+
+ /** @return average response time in milliseconds, or 0 if no responses yet */
+ public double getAverageResponseTime() {
+ long count = responseCount.get();
+ return count > 0 ? (double) totalResponseTime.get() / count : 0;
+ }
+
+ /** @return error rate as a percentage (0-100), or 0 if no attempts yet */
+ public double getErrorRate() {
+ long total = validationCount.sum();
+ return total > 0 ? (double) errorCount.sum() * 100 / total : 0;
+ }
+
+ /**
+ * @deprecated Use {@link #getTurnstileSitekey()} instead.
+ */
+ @Deprecated
+ public String getTurnsiteSitekey() { return getTurnstileSitekey(); }
+
+ /** @return the Turnstile Sitekey */
+ public String getTurnstileSitekey() { return properties.getSitekey(); }
+
+ /**
+ * Gets the client IP address from the ServletRequest.
+ *
+ * @param request the ServletRequest.
+ * @return the client IP address.
+ */
+ public String getClientIpAddress(ServletRequest request) {
+ if (request instanceof HttpServletRequest httpRequest) {
+ String[] headers = {"X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};
+ for (String header : headers) {
+ String ipHeaderValue = httpRequest.getHeader(header);
+ if (ipHeaderValue == null || ipHeaderValue.isBlank()) {
+ continue;
+ }
+ String candidate = ipHeaderValue.split(",", 2)[0].trim();
+ if (!candidate.isEmpty() && !UNKNOWN.equalsIgnoreCase(candidate)) {
+ return candidate;
+ }
+ }
+ }
+ return request.getRemoteAddr();
+ }
+}
+```
+
+- [ ] **Step 2: Compile to verify**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 3: Run existing tests to verify no regressions**
+
+```bash
+./gradlew test
+```
+Expected: All tests pass (they will fail until Task 5 wires the `TurnstileMetrics` bean — the constructor signature changed, so Spring context won't load yet). If they fail with `NoSuchBeanDefinitionException: TurnstileMetrics`, that's expected — continue to Task 5.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
+git commit -m "refactor: remove Micrometer imports from TurnstileValidationService, use TurnstileMetrics interface"
+```
+
+---
+
+## Task 5: Register `TurnstileMetrics` beans in configuration
+
+Wire the correct `TurnstileMetrics` bean into Spring context: `NoOpTurnstileMetrics` when Micrometer is absent (via `@ConditionalOnMissingBean`), `MicrometerTurnstileMetrics` when Micrometer is present (registered in `TurnstileMetricsConfig`).
+
+**Files:**
+- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java`
+- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java`
+
+- [ ] **Step 1: Replace `TurnstileServiceConfig` — remove `ObjectProvider`, add no-op bean, fix service constructor call**
+
+Replace the entire file content with:
+
+```java
+package com.digitalsanctuary.cf.turnstile.config;
+
+import java.net.http.HttpClient;
+import java.time.Duration;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics;
+import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
+import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Configuration class for setting up Turnstile related beans.
+ */
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class TurnstileServiceConfig {
+
+ private final TurnstileConfigProperties properties;
+
+ /**
+ * Provides a no-op TurnstileMetrics bean when no other implementation is registered.
+ * This is the fallback when Micrometer is not on the classpath.
+ *
+ * @return a no-op TurnstileMetrics instance
+ */
+ @Bean
+ @ConditionalOnMissingBean(TurnstileMetrics.class)
+ public TurnstileMetrics noOpTurnstileMetrics() {
+ log.info("Micrometer not available — using no-op Turnstile metrics");
+ return new NoOpTurnstileMetrics();
+ }
+
+ /**
+ * Creates a TurnstileValidationService bean.
+ *
+ * @param restClient the preconfigured REST client for Turnstile calls
+ * @param metrics the TurnstileMetrics implementation to use
+ * @return a configured TurnstileValidationService instance
+ */
+ @Bean
+ public TurnstileValidationService turnstileValidationService(
+ @Qualifier("turnstileRestClient") RestClient restClient,
+ TurnstileMetrics metrics) {
+ return new TurnstileValidationService(restClient, properties, metrics);
+ }
+
+ /**
+ * Creates a RestClient bean for Turnstile API interactions.
+ *
+ * @return a configured RestClient instance
+ */
+ @Bean(name = "turnstileRestClient")
+ public RestClient turnstileRestClient() {
+ log.info("Creating Turnstile REST client with endpoint: {}", properties.getUrl());
+ log.info("Turnstile REST client timeouts - connect: {}s, read: {}s",
+ properties.getConnectTimeout(), properties.getReadTimeout());
+
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(properties.getConnectTimeout()))
+ .build();
+
+ JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
+ requestFactory.setReadTimeout(Duration.ofSeconds(properties.getReadTimeout()));
+
+ return RestClient.builder()
+ .baseUrl(properties.getUrl())
+ .requestFactory(requestFactory)
+ .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .build();
+ }
+}
+```
+
+- [ ] **Step 2: Add `MicrometerTurnstileMetrics` bean to `TurnstileMetricsConfig`**
+
+Replace the entire file content with:
+
+```java
+package com.digitalsanctuary.cf.turnstile.config;
+
+import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics;
+import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tag;
+import io.micrometer.core.instrument.config.MeterFilter;
+import java.util.Collections;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Configuration for Turnstile metrics and monitoring.
+ * Only loaded when Micrometer is on the classpath and metrics are enabled.
+ */
+@Slf4j
+@Configuration
+@ConditionalOnClass(MeterRegistry.class)
+@ConditionalOnProperty(prefix = "ds.cf.turnstile.metrics", name = "enabled", havingValue = "true", matchIfMissing = true)
+public class TurnstileMetricsConfig {
+
+ /**
+ * Registers the Micrometer-backed TurnstileMetrics bean.
+ *
+ * @param registry the MeterRegistry to use for metrics
+ * @return a MicrometerTurnstileMetrics instance
+ */
+ @Bean
+ public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) {
+ return new MicrometerTurnstileMetrics(registry);
+ }
+
+ /**
+ * Customizes the meter registry with Turnstile-specific tags and filters.
+ *
+ * @return a MeterRegistryCustomizer for the MeterRegistry
+ */
+ @Bean
+ public MeterRegistryCustomizer turnstileMeterRegistryCustomizer() {
+ log.info("Configuring Turnstile metrics");
+ return registry -> registry.config()
+ .meterFilter(MeterFilter.acceptNameStartsWith("turnstile"))
+ .meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile"))));
+ }
+}
+```
+
+- [ ] **Step 3: Compile to verify**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 4: Run existing tests**
+
+```bash
+./gradlew test
+```
+Expected: All existing tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java \
+ src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java
+git commit -m "feat: wire TurnstileMetrics beans — Micrometer-backed or no-op based on classpath"
+```
+
+---
+
+## Task 6: Fix `TurnstileConfiguration` — remove direct Micrometer import, use string-form `@ConditionalOnClass`
+
+The outer auto-configuration class references `MeterRegistry` as a class literal in an annotation, which encodes it into the bytecode. Switch to the safe string form.
+
+**Files:**
+- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java`
+
+- [ ] **Step 1: Update `TurnstileConfiguration`**
+
+Replace the entire file content with:
+
+```java
+package com.digitalsanctuary.cf.turnstile;
+
+import org.springframework.boot.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties;
+import com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator;
+import com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig;
+import com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig;
+import com.digitalsanctuary.cf.turnstile.filter.TurnstileCaptchaFilter;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Main auto-configuration class for the Spring Cloudflare Turnstile integration.
+ *
+ * Imports core configuration unconditionally; metrics and health configurations are
+ * conditional on the presence of their respective classes on the classpath.
+ *
+ *
+ * @see com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties
+ * @see com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig
+ * @see com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
+ * @see com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig
+ * @see com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator
+ */
+@Slf4j
+@Configuration
+@AutoConfiguration
+@Import({TurnstileServiceConfig.class, TurnstileConfigProperties.class, TurnstileCaptchaFilter.class})
+public class TurnstileConfiguration {
+
+ /**
+ * Metrics configuration for Turnstile.
+ * Only imported if Micrometer's MeterRegistry is available on the classpath.
+ * Uses string form of @ConditionalOnClass to avoid encoding a bytecode reference
+ * to MeterRegistry in this class, which would cause NoClassDefFoundError when
+ * Micrometer is absent.
+ */
+ @Configuration
+ @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")
+ @Import(TurnstileMetricsConfig.class)
+ static class TurnstileMetricsConfiguration {
+ }
+
+ /**
+ * Health indicator configuration for Turnstile.
+ * Only imported if Spring Actuator's HealthIndicator is available on the classpath.
+ */
+ @Configuration
+ @ConditionalOnEnabledHealthIndicator("turnstile")
+ @ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator")
+ @Import(TurnstileHealthIndicator.class)
+ static class TurnstileHealthConfiguration {
+ }
+
+ /**
+ * Logs confirmation that the Turnstile service has been loaded.
+ */
+ @PostConstruct
+ public void onStartup() {
+ log.info("DigitalSanctuary Spring Cloudflare Turnstile Service loaded");
+ }
+}
+```
+
+- [ ] **Step 2: Compile to verify**
+
+```bash
+./gradlew compileJava
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 3: Run all tests**
+
+```bash
+./gradlew test
+```
+Expected: All tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java
+git commit -m "fix: use string-form @ConditionalOnClass in TurnstileConfiguration to prevent NoClassDefFoundError when Micrometer is absent"
+```
+
+---
+
+## Task 7: Add test verifying the library loads without Actuator
+
+Write a Spring context test that excludes `MeterRegistry` from the context, simulating a consumer app without Actuator. This is the regression test for issue #94.
+
+**Files:**
+- Create: `src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java`
+
+- [ ] **Step 1: Create the test**
+
+```java
+package com.digitalsanctuary.cf.test.turnstile;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import com.digitalsanctuary.cf.test.TestApplication;
+import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics;
+import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
+import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService;
+
+/**
+ * Verifies that the library starts successfully without Micrometer/Actuator on the classpath.
+ * Simulates this by excluding all Micrometer-related auto-configurations.
+ * Regression test for GitHub issue #94.
+ */
+@SpringBootTest(
+ classes = TestApplication.class,
+ properties = {
+ "spring.autoconfigure.exclude=" +
+ "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration," +
+ "org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration"
+ }
+)
+@ActiveProfiles("test")
+class TurnstileWithoutActuatorTest {
+
+ @Autowired
+ private TurnstileValidationService turnstileValidationService;
+
+ @Autowired
+ private TurnstileMetrics turnstileMetrics;
+
+ @Test
+ void contextLoadsWithoutMicrometer() {
+ assertNotNull(turnstileValidationService, "TurnstileValidationService should be available");
+ }
+
+ @Test
+ void noOpMetricsUsedWhenMicrometerExcluded() {
+ assertInstanceOf(NoOpTurnstileMetrics.class, turnstileMetrics,
+ "Should use NoOpTurnstileMetrics when Micrometer auto-config is excluded");
+ }
+
+ @Test
+ void validationWorksWithoutMicrometer() {
+ // Short token triggers INPUT_ERROR path — exercises metrics recording via NoOp
+ boolean result = turnstileValidationService.validateTurnstileResponse("short");
+ assertNotNull(result); // just verifying no exception from metrics recording
+ }
+}
+```
+
+- [ ] **Step 2: Run the new test**
+
+```bash
+./gradlew test --tests "com.digitalsanctuary.cf.test.turnstile.TurnstileWithoutActuatorTest"
+```
+Expected: All 3 tests pass.
+
+- [ ] **Step 3: Run the full test suite**
+
+```bash
+./gradlew test
+```
+Expected: All tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java
+git commit -m "test: add regression test for issue #94 — library loads without Actuator"
+```
+
+---
+
+## Task 8: Final verification
+
+- [ ] **Step 1: Clean build**
+
+```bash
+./gradlew clean build
+```
+Expected: `BUILD SUCCESSFUL`, all tests pass.
+
+- [ ] **Step 2: Verify no stray Micrometer imports in always-loaded classes**
+
+```bash
+grep -r "import io.micrometer" \
+ src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java \
+ src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java \
+ src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
+```
+Expected: No output (zero matches).
+
+- [ ] **Step 3: Verify Micrometer imports are only in conditional classes**
+
+```bash
+grep -r "import io.micrometer" src/main/java/ --include="*.java" -l
+```
+Expected: Only `TurnstileMetricsConfig.java` and `MicrometerTurnstileMetrics.java` appear.
+
+- [ ] **Step 4: Publish to local Maven and verify POM**
+
+```bash
+./gradlew publishToMavenLocal
+```
+Expected: `BUILD SUCCESSFUL`. Verify `spring-boot-starter-actuator` is listed as `optional` or absent from the published POM's `` (it was `compileOnly` so it should not appear in the POM).
+
+- [ ] **Step 5: Commit final verification results**
+
+No code changes; if all checks pass, proceed to create a PR.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- ✅ `NoClassDefFoundError` when Micrometer absent — fixed by removing type references from always-loaded classes
+- ✅ Micrometer metrics still work when Actuator is present — `MicrometerTurnstileMetrics` registered via `TurnstileMetricsConfig`
+- ✅ Health indicator still works (not touched — already uses string-form `@ConditionalOnClass`)
+- ✅ `@ConditionalOnClass` uses safe string form in `TurnstileConfiguration`
+- ✅ Regression test added
+
+**Type consistency check:**
+- `TurnstileMetrics` interface methods: `recordValidation()`, `recordSuccess()`, `recordError(ValidationResultType)`, `recordResponseTime(long)` — consistent across `NoOpTurnstileMetrics`, `MicrometerTurnstileMetrics`, and all call sites in `TurnstileValidationService`
+- `TurnstileServiceConfig.turnstileValidationService(RestClient, TurnstileMetrics)` — matches `TurnstileValidationService` constructor signature
+
+**Placeholder scan:** None found — all steps contain exact code.
From 077f3006d8d0de77ddc6308c085c8f2a7eda4bb5 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Fri, 1 May 2026 11:42:31 -0600
Subject: [PATCH 14/15] =?UTF-8?q?fix:=20address=20PR=20review=20findings?=
=?UTF-8?q?=20=E2=80=94=20double-counting,=20finally=20safety,=20JavaDoc,?=
=?UTF-8?q?=20unit=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove duplicate recordError(INVALID_TOKEN) call from executeValidationRequest;
outer catch (TurnstileValidationException) is the single correct recording site
- Wrap metrics.recordResponseTime() in try/catch inside finally block so a metrics
failure cannot suppress the original validation exception
- Add log.debug to TurnstileValidationException catch for symmetry with other handlers
- Improve broad catch to log exception class name for better diagnostics
- Add complete JavaDoc to TurnstileMetrics interface (recordValidation, recordSuccess,
recordResponseTime) including call-sequence contract and timing semantics
- Add class and constructor JavaDoc to MicrometerTurnstileMetrics; add
Objects.requireNonNull guard on registry parameter
- Fix TurnstileValidationService class-level doc (no longer says "when metrics enabled")
- Fix TurnstileHealthConfiguration comment to mention @ConditionalOnEnabledHealthIndicator
- Fix noOpTurnstileMetrics comment to say "no TurnstileMetrics bean registered"
rather than "Micrometer not on classpath"
- Add MicrometerTurnstileMetricsTest (9 tests) covering all counter dispatch paths,
timer recording, null-registry rejection, and aggregate/sub-counter consistency
---
.../cf/turnstile/TurnstileConfiguration.java | 4 +-
.../config/TurnstileServiceConfig.java | 6 +-
.../metrics/MicrometerTurnstileMetrics.java | 33 ++++++-
.../turnstile/metrics/TurnstileMetrics.java | 29 +++++-
.../service/TurnstileValidationService.java | 14 ++-
.../MicrometerTurnstileMetricsTest.java | 98 +++++++++++++++++++
6 files changed, 173 insertions(+), 11 deletions(-)
create mode 100644 src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java
index b49ffec..a6bd9ae 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java
@@ -47,7 +47,9 @@ static class TurnstileMetricsConfiguration {
/**
* Health indicator configuration for Turnstile.
- * Only imported if Spring Actuator's HealthIndicator is available on the classpath.
+ * Only imported if Spring Actuator's {@code HealthIndicator} class is on the classpath
+ * and the turnstile health indicator has not been disabled via
+ * {@code management.health.turnstile.enabled=false}.
*/
@Configuration
@ConditionalOnEnabledHealthIndicator("turnstile")
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
index 23756e0..85e58ad 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
@@ -27,8 +27,10 @@ public class TurnstileServiceConfig {
private final TurnstileConfigProperties properties;
/**
- * Provides a no-op TurnstileMetrics bean when no other implementation is registered.
- * This is the fallback when Micrometer is not on the classpath.
+ * Provides a no-op {@link TurnstileMetrics} bean when no other implementation is registered.
+ * This fallback is active when Micrometer is absent from the classpath, when metrics are
+ * disabled via {@code ds.cf.turnstile.metrics.enabled=false}, or when no custom
+ * {@code TurnstileMetrics} bean has been supplied by the consuming application.
*
* @return a no-op TurnstileMetrics instance
*/
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
index 279dc9e..7d6d9dc 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java
@@ -4,12 +4,24 @@
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
+import java.util.Objects;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
/**
- * Micrometer-backed implementation of TurnstileMetrics.
- * This class is only instantiated when Micrometer is present on the classpath.
+ * Micrometer-backed implementation of {@link TurnstileMetrics}.
+ *
+ * This class is only instantiated when Micrometer is present on the classpath, via
+ * {@code TurnstileMetricsConfig} which is guarded by {@code @ConditionalOnClass(MeterRegistry.class)}.
+ *
+ *
+ * All eight meters ({@code errorCounter} is the aggregate; the four type-specific counters are
+ * sub-categories whose sum equals the aggregate) are eagerly registered at construction time so
+ * they appear in monitoring dashboards before the first validation event occurs. A single instance
+ * should be registered per application context to avoid duplicate meter registration errors.
+ *
+ *
+ * @see NoOpTurnstileMetrics
*/
@Slf4j
public class MicrometerTurnstileMetrics implements TurnstileMetrics {
@@ -23,7 +35,24 @@ public class MicrometerTurnstileMetrics implements TurnstileMetrics {
private final Counter inputErrorCounter;
private final Timer responseTimer;
+ /**
+ * Creates a {@code MicrometerTurnstileMetrics} instance and eagerly registers all Turnstile
+ * meters with the provided registry. The registered meters are:
+ *
+ * - {@code turnstile.validation.requests} — total attempts
+ * - {@code turnstile.validation.success} — successful validations
+ * - {@code turnstile.validation.errors} — all errors (aggregate)
+ * - {@code turnstile.validation.errors.network} — network errors
+ * - {@code turnstile.validation.errors.config} — configuration errors
+ * - {@code turnstile.validation.errors.token} — invalid token errors
+ * - {@code turnstile.validation.errors.input} — input validation errors
+ * - {@code turnstile.validation.response.time} — response time timer
+ *
+ *
+ * @param registry the Micrometer {@link MeterRegistry} to register meters with; must not be null
+ */
public MicrometerTurnstileMetrics(MeterRegistry registry) {
+ Objects.requireNonNull(registry, "registry must not be null");
log.info("Initializing Turnstile metrics with MeterRegistry");
validationCounter = Counter.builder("turnstile.validation.requests")
.description("Total number of Turnstile validation requests").register(registry);
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java
index d47e14d..f35c564 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java
@@ -4,10 +4,30 @@
/**
* Abstraction for recording Turnstile validation metrics.
- * Implementations may be no-op (when Micrometer is absent) or Micrometer-backed.
+ *
+ * Implementations may be no-op (when Micrometer is absent from the classpath or metrics are
+ * disabled) or Micrometer-backed. Consumers may also supply a custom implementation as a Spring
+ * bean to integrate with their own metrics infrastructure.
+ *
+ *
+ * Expected call sequence per validation attempt:
+ * {@link #recordValidation()} is always called first, followed by exactly one of
+ * {@link #recordSuccess()} or {@link #recordError(ValidationResultType)}, and then
+ * {@link #recordResponseTime(long)} for any attempt that reached the network (input and
+ * configuration errors do not record a response time).
+ *
*/
public interface TurnstileMetrics {
+
+ /**
+ * Records a validation attempt. Called once per invocation of
+ * {@code validateTurnstileResponseDetailed}, regardless of outcome.
+ */
void recordValidation();
+
+ /**
+ * Records a successful validation. Called only when Cloudflare returns a success response.
+ */
void recordSuccess();
/**
@@ -22,5 +42,12 @@ public interface TurnstileMetrics {
*/
void recordError(ValidationResultType type);
+ /**
+ * Records the elapsed wall-clock time for a validation attempt that reached the network.
+ * Not called for input or configuration errors that short-circuit before the HTTP request.
+ *
+ * @param milliseconds elapsed time in milliseconds from the start of the validation call
+ * to its completion (success or failure)
+ */
void recordResponseTime(long milliseconds);
}
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
index e59c937..b1abccd 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java
@@ -29,8 +29,8 @@
* Service for validating responses from Cloudflare's Turnstile API.
*
* This service provides methods to validate Turnstile tokens with the Cloudflare API, handling various error scenarios with appropriate exceptions
- * and detailed validation results. It also collects metrics on validation attempts, success/failure rates, and response times when metrics are
- * enabled.
+ * and detailed validation results. It maintains internal counters for validation attempts, success/failure rates, and response times, and delegates
+ * to a {@link com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics} implementation (Micrometer-backed or no-op) for external metric recording.
*
*/
@Slf4j
@@ -207,10 +207,11 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r
recordError(ValidationResultType.NETWORK_ERROR);
throw new TurnstileNetworkException("Network error: " + e.getMessage(), e);
} catch (TurnstileValidationException e) {
+ log.debug("Turnstile token rejected by Cloudflare: {}", e.getMessage());
recordError(ValidationResultType.INVALID_TOKEN);
throw e;
} catch (Exception e) {
- log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e);
+ log.error("Unexpected {} during Turnstile validation: {}", e.getClass().getSimpleName(), e.getMessage(), e);
recordError(ValidationResultType.NETWORK_ERROR);
throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e);
} finally {
@@ -218,7 +219,11 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r
lastResponseTime.set(elapsed);
totalResponseTime.addAndGet(elapsed);
responseCount.incrementAndGet();
- metrics.recordResponseTime(elapsed);
+ try {
+ metrics.recordResponseTime(elapsed);
+ } catch (Exception metricsEx) {
+ log.warn("Failed to record response time metric; validation result is unaffected: {}", metricsEx.getMessage(), metricsEx);
+ }
}
}
@@ -241,7 +246,6 @@ private ValidationResult executeValidationRequest(Map requestBod
return ValidationResult.success();
} else {
log.warn("Turnstile validation failed with error codes: {}", response.getErrorCodes());
- recordError(ValidationResultType.INVALID_TOKEN);
throw new TurnstileValidationException("Token validation failed", response.getErrorCodes());
}
}
diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java
new file mode 100644
index 0000000..75fbef6
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java
@@ -0,0 +1,98 @@
+package com.digitalsanctuary.cf.test.turnstile;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
+import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics;
+
+/**
+ * Unit tests for MicrometerTurnstileMetrics using an in-memory SimpleMeterRegistry.
+ * Verifies that each method routes to the correct named Micrometer meter.
+ */
+class MicrometerTurnstileMetricsTest {
+
+ private SimpleMeterRegistry registry;
+ private MicrometerTurnstileMetrics metrics;
+
+ @BeforeEach
+ void setUp() {
+ registry = new SimpleMeterRegistry();
+ metrics = new MicrometerTurnstileMetrics(registry);
+ }
+
+ @Test
+ void recordValidation_incrementsRequestsCounter() {
+ metrics.recordValidation();
+ metrics.recordValidation();
+ assertEquals(2.0, registry.counter("turnstile.validation.requests").count());
+ }
+
+ @Test
+ void recordSuccess_incrementsSuccessCounter() {
+ metrics.recordSuccess();
+ assertEquals(1.0, registry.counter("turnstile.validation.success").count());
+ }
+
+ @Test
+ void recordError_networkError_incrementsAggregateAndSubcounter() {
+ metrics.recordError(ValidationResultType.NETWORK_ERROR);
+ assertEquals(1.0, registry.counter("turnstile.validation.errors").count());
+ assertEquals(1.0, registry.counter("turnstile.validation.errors.network").count());
+ assertEquals(0.0, registry.counter("turnstile.validation.errors.config").count());
+ assertEquals(0.0, registry.counter("turnstile.validation.errors.token").count());
+ assertEquals(0.0, registry.counter("turnstile.validation.errors.input").count());
+ }
+
+ @Test
+ void recordError_configurationError_incrementsAggregateAndSubcounter() {
+ metrics.recordError(ValidationResultType.CONFIGURATION_ERROR);
+ assertEquals(1.0, registry.counter("turnstile.validation.errors").count());
+ assertEquals(0.0, registry.counter("turnstile.validation.errors.network").count());
+ assertEquals(1.0, registry.counter("turnstile.validation.errors.config").count());
+ }
+
+ @Test
+ void recordError_invalidToken_incrementsAggregateAndSubcounter() {
+ metrics.recordError(ValidationResultType.INVALID_TOKEN);
+ assertEquals(1.0, registry.counter("turnstile.validation.errors").count());
+ assertEquals(1.0, registry.counter("turnstile.validation.errors.token").count());
+ }
+
+ @Test
+ void recordError_inputError_incrementsAggregateAndSubcounter() {
+ metrics.recordError(ValidationResultType.INPUT_ERROR);
+ assertEquals(1.0, registry.counter("turnstile.validation.errors").count());
+ assertEquals(1.0, registry.counter("turnstile.validation.errors.input").count());
+ }
+
+ @Test
+ void recordResponseTime_recordsToTimer() {
+ metrics.recordResponseTime(150L);
+ metrics.recordResponseTime(250L);
+ assertEquals(2L, registry.timer("turnstile.validation.response.time").count());
+ }
+
+ @Test
+ void constructor_rejectsNullRegistry() {
+ assertThrows(NullPointerException.class, () -> new MicrometerTurnstileMetrics(null));
+ }
+
+ @Test
+ void multipleErrors_aggregateCountEqualsSum() {
+ metrics.recordError(ValidationResultType.NETWORK_ERROR);
+ metrics.recordError(ValidationResultType.INVALID_TOKEN);
+ metrics.recordError(ValidationResultType.INPUT_ERROR);
+
+ Counter aggregate = registry.counter("turnstile.validation.errors");
+ double subTotal = registry.counter("turnstile.validation.errors.network").count()
+ + registry.counter("turnstile.validation.errors.config").count()
+ + registry.counter("turnstile.validation.errors.token").count()
+ + registry.counter("turnstile.validation.errors.input").count();
+
+ assertEquals(aggregate.count(), subTotal, "Aggregate error count must equal sum of sub-counters");
+ }
+}
From dfafe6bec1e6652ae2cc24c4fb29e4198eb1dfb3 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Fri, 1 May 2026 12:04:50 -0600
Subject: [PATCH 15/15] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?=
=?UTF-8?q?=20@ConditionalOnBean=20guard=20and=20accurate=20log=20message?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add @ConditionalOnBean(MeterRegistry.class) to micrometerTurnstileMetrics so
the Micrometer-backed bean only registers when a MeterRegistry bean actually
exists, gracefully falling back to NoOpTurnstileMetrics otherwise.
Update noOpTurnstileMetrics log message to accurately describe all fallback
scenarios (no Micrometer, metrics disabled, no registry available).
Update TurnstileMetricsWiringTest to assert the correct post-fix behavior:
NoOpTurnstileMetrics is active when MetricsAutoConfiguration is excluded and
no MeterRegistry bean is present.
---
.../cf/turnstile/config/TurnstileMetricsConfig.java | 2 ++
.../cf/turnstile/config/TurnstileServiceConfig.java | 2 +-
.../test/turnstile/TurnstileMetricsWiringTest.java | 12 +++++++-----
3 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java
index 9d5dc39..48244a5 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java
@@ -1,6 +1,7 @@
package com.digitalsanctuary.cf.turnstile.config;
import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -31,6 +32,7 @@ public class TurnstileMetricsConfig {
* @return a MicrometerTurnstileMetrics instance
*/
@Bean
+ @ConditionalOnBean(MeterRegistry.class)
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) {
return new MicrometerTurnstileMetrics(registry);
diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
index 85e58ad..e928bb0 100644
--- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
+++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java
@@ -37,7 +37,7 @@ public class TurnstileServiceConfig {
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics noOpTurnstileMetrics() {
- log.info("Micrometer not available — using no-op Turnstile metrics");
+ log.info("No TurnstileMetrics bean available — using no-op Turnstile metrics");
return new NoOpTurnstileMetrics();
}
diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
index e83bd4a..ab2da8c 100644
--- a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
+++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java
@@ -38,15 +38,17 @@ class TurnstileMetricsWiringTest {
private TurnstileMetrics turnstileMetrics;
/**
- * Verifies that a TurnstileMetrics bean is available when metrics auto-configs are excluded.
- * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active.
+ * Verifies that the NoOp fallback is active when MetricsAutoConfiguration is excluded.
+ * Even though Micrometer is on the classpath, no MeterRegistry bean is registered when
+ * MetricsAutoConfiguration is excluded, so @ConditionalOnBean(MeterRegistry.class) fails
+ * and the NoOpTurnstileMetrics fallback is used instead.
*/
@Test
- void metricsAvailableWhenMicrometerAutoConfigExcluded() {
+ void noOpMetricsActiveWhenMicrometerAutoConfigExcluded() {
assertNotNull(turnstileMetrics,
"A TurnstileMetrics bean should be available even with Micrometer auto-config excluded");
- assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics,
- "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath");
+ assertTrue(turnstileMetrics instanceof NoOpTurnstileMetrics,
+ "NoOpTurnstileMetrics should be active when no MeterRegistry bean is available");
}
/**