From 64599665a54179f99d400d7ddec87ee5afa21659 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 22:29:20 +0200 Subject: [PATCH 01/32] fix: fixed an issue where a NullPointerException was thrown on unset environment variables --- .../ktestify/io/inputs/DynamicVariableProcessor.java | 11 ++++++----- .../ktestify/io/inputs/types/EnvironmentVariable.java | 11 +++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java index 8c462fb..2de6b94 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java @@ -15,15 +15,16 @@ */ package io.github.ktestify.io.inputs; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class DynamicVariableProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableProcessor.class); - private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)(?::([^}]*?))?\\}\\}"); + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)(?::([^}]*?))?}}"); private static final int GROUP_NAME = 1; private static final int GROUP_FORMAT = 2; @@ -34,7 +35,7 @@ public String process(String input) { } Matcher matcher = VARIABLE_PATTERN.matcher(input); - StringBuffer sb = new StringBuffer(); + StringBuilder sb = new StringBuilder(); // Check if any matches exist if (!matcher.find()) { @@ -54,7 +55,7 @@ public String process(String input) { LOGGER.info("Processing dynamic variable: {} with format: {}", variableName, format); DynamicVariable variable = DynamicVariableFactory.getVariable(variableName); String replacement = variable.process(format); - matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + sb.append(replacement); } else { LOGGER.warn("Dynamic variable {} is not registered, keeping it unchanged.", variableName); matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0))); diff --git a/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java index 2fbb782..587730b 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java @@ -15,6 +15,7 @@ */ package io.github.ktestify.io.inputs.types; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.io.inputs.DynamicVariable; public class EnvironmentVariable implements DynamicVariable { @@ -26,8 +27,14 @@ public String getName() { @Override public String process(String format) { if (format == null || format.isEmpty()) { - throw new IllegalArgumentException("Environment variable name cannot be null or empty"); + throw ConfigException.missingValue("env"); } - return System.getenv(format); + + String value = System.getenv(format); + if (value == null) { + throw new ConfigException("Environment variable '" + format + "' is not set"); + } + + return value; } } From a8aa2575c87efbb90bb063cbd35d1817933aed1e Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 22:30:07 +0200 Subject: [PATCH 02/32] fix: close URLClassLoader in PluginRegistry to prevent file handle leak --- .../ktestify/plugin/PluginRegistry.java | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java index fedd2ca..f875f71 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -16,13 +16,14 @@ package io.github.ktestify.plugin; import io.github.ktestify.exceptions.PluginException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.*; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Discovers, loads, initializes, and holds all active {@link KtestifyPlugin} instances for the current JVM run. @@ -71,8 +72,16 @@ public final class PluginRegistry { private final List plugins; - private PluginRegistry(List plugins) { + /** + * The {@link URLClassLoader} created for external plugin JARs, or {@code null} if no external plugins were loaded. + *

Kept open for the lifetime of the registry because loaded plugin classes reference it. It is closed in + * {@link #shutdown()} after all plugins have been shut down. + */ + private final URLClassLoader externalClassLoader; + + private PluginRegistry(List plugins, URLClassLoader externalClassLoader) { this.plugins = Collections.unmodifiableList(plugins); + this.externalClassLoader = externalClassLoader; } // ------------------------------------------------------------------------- @@ -88,6 +97,7 @@ private PluginRegistry(List plugins) { */ public static PluginRegistry load(PluginContext ctx) { List all = new ArrayList<>(); + URLClassLoader externalCL = null; LOG.info("Loading plugins..."); @@ -96,7 +106,15 @@ public static PluginRegistry load(PluginContext ctx) { // Phase 2 — external plugin directory String pluginsDir = resolvePluginsDir(ctx); - loadFromDirectory(pluginsDir, ctx, all); + try { + externalCL = loadFromDirectory(pluginsDir, ctx, all); + } catch (RuntimeException e) { + // If loading from directory fails after the classloader was created, close it to avoid a leak. + if (externalCL != null) { + closeClassLoaderQuietly(externalCL); + } + throw e; + } if (all.isEmpty()) { LOG.info("No plugins loaded."); @@ -107,7 +125,7 @@ public static PluginRegistry load(PluginContext ctx) { all.stream().map(p -> p.getId() + "@" + p.getVersion()).collect(Collectors.joining(", "))); } - return new PluginRegistry(all); + return new PluginRegistry(all, externalCL); } // ------------------------------------------------------------------------- @@ -139,7 +157,8 @@ public List getGluePackages() { } /** - * Shuts down all plugins in reverse initialization order. + * Shuts down all plugins in reverse initialization order, then closes the external plugin {@link URLClassLoader} + * if one was created. * *

Exceptions thrown by individual plugins are caught, logged as warnings, and swallowed so the remaining plugins * can still be shut down cleanly. @@ -156,6 +175,11 @@ public void shutdown() { LOG.warn("Error shutting down plugin '{}' — ignored: {}", plugin.getId(), e.getMessage()); } } + + // Close the external URLClassLoader to release file handles on plugin JARs. + if (externalClassLoader != null) { + closeClassLoaderQuietly(externalClassLoader); + } } // ------------------------------------------------------------------------- @@ -184,23 +208,26 @@ private static void loadFromClasspath(PluginContext ctx, List ta /** * Phase 2: scans an external directory for {@code .jar} files, loads them via a {@link URLClassLoader}, and * discovers plugins inside each JAR. + * + * @return the {@link URLClassLoader} created for the external JARs, or {@code null} if no external plugins were + * loaded (directory not configured, missing, or empty) */ - private static void loadFromDirectory(String dirPath, PluginContext ctx, List target) { + private static URLClassLoader loadFromDirectory(String dirPath, PluginContext ctx, List target) { if (dirPath == null || dirPath.isBlank()) { LOG.debug("Phase 2 (external): plugins dir not configured."); - return; + return null; } File dir = new File(dirPath); if (!dir.exists() || !dir.isDirectory()) { LOG.debug("Phase 2 (external): directory '{}' does not exist.", dirPath); - return; + return null; } File[] jars = dir.listFiles(f -> f.isFile() && f.getName().endsWith(".jar")); if (jars == null || jars.length == 0) { LOG.debug("Phase 2 (external): no *.jar files found in '{}'", dirPath); - return; + return null; } LOG.info("[external] Scanning '{}' — {} JAR(s) found.", dirPath, jars.length); @@ -234,6 +261,18 @@ private static void loadFromDirectory(String dirPath, PluginContext ctx, List Date: Thu, 1 Oct 2026 22:33:59 +0200 Subject: [PATCH 03/32] fix: add missing schemas directory default to reference.conf --- src/main/resources/reference.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 5ef30e4..97d662c 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -135,6 +135,10 @@ ktestify { assets = "" assets = ${?KTESTIFY_ASSETS_DIR} + # Directory for Avro/JSON schema files + schemas = "" + schemas = ${?KTESTIFY_SCHEMAS_DIR} + # Directory for test output/reports output = "" output = ${?KTESTIFY_OUTPUT_DIR} From 1e5fcf79e1a7a892f2b01853c614419578139951 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 22:39:45 +0200 Subject: [PATCH 04/32] fix: harden XML parsers against XXE attacks in XMLUtils --- .../io/inputs/DynamicVariableProcessor.java | 2 +- .../io/github/ktestify/utils/XMLUtils.java | 102 +++++++++++++++--- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java index 2de6b94..ac4e358 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java @@ -55,7 +55,7 @@ public String process(String input) { LOGGER.info("Processing dynamic variable: {} with format: {}", variableName, format); DynamicVariable variable = DynamicVariableFactory.getVariable(variableName); String replacement = variable.process(format); - sb.append(replacement); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); } else { LOGGER.warn("Dynamic variable {} is not registered, keeping it unchanged.", variableName); matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0))); diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index 8ed212a..e5b4111 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -15,27 +15,25 @@ */ package io.github.ktestify.utils; -import java.io.StringReader; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xml.sax.Attributes; import org.xml.sax.InputSource; +import org.xml.sax.SAXNotRecognizedException; +import org.xml.sax.SAXNotSupportedException; import org.xml.sax.helpers.DefaultHandler; import org.xmlunit.builder.DiffBuilder; -import org.xmlunit.diff.Comparison; -import org.xmlunit.diff.ComparisonResult; -import org.xmlunit.diff.ComparisonType; -import org.xmlunit.diff.DefaultNodeMatcher; -import org.xmlunit.diff.Diff; -import org.xmlunit.diff.Difference; -import org.xmlunit.diff.DifferenceEvaluator; -import org.xmlunit.diff.ElementSelectors; +import org.xmlunit.diff.*; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. @@ -48,6 +46,77 @@ public final class XMLUtils { private static final Logger LOGGER = LoggerFactory.getLogger(XMLUtils.class); private static final int XMLNS_PREFIX_LENGTH = "xmlns:".length(); + /** + * Creates a hardened {@link SAXParserFactory} with XXE protections enabled. + * + *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. + * + * @return a hardened SAXParserFactory + */ + private static SAXParserFactory newHardenedSAXParserFactory() { + SAXParserFactory factory = SAXParserFactory.newInstance(); + // Namespace-awareness is intentionally NOT enabled here: getNamespacesFromString + // relies on xmlns:* attributes being reported as regular attributes via getQName(), + // which namespace-aware mode would suppress. XXE hardening features below are + // independent of this setting. + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disallow DOCTYPE declarations on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external general entities on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external parameter entities on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external DTD loading on SAXParserFactory: {}", e.getMessage()); + } + return factory; + } + + /** + * Creates a hardened {@link DocumentBuilderFactory} with XXE protections enabled. + * + *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. + * Used by XMLUnit's {@link DiffBuilder} via {@code withDocumentBuilderFactory}. + * + * @return a hardened DocumentBuilderFactory + */ + private static DocumentBuilderFactory newHardenedDocumentBuilderFactory() { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disallow DOCTYPE declarations on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external general entities on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external parameter entities on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external DTD loading on DocumentBuilderFactory: {}", e.getMessage()); + } + factory.setExpandEntityReferences(false); + factory.setXIncludeAware(false); + return factory; + } + /** * Extracts XML namespace declarations from a raw XML string. * @@ -57,7 +126,7 @@ public final class XMLUtils { public static Map getNamespacesFromString(String xmlContent) { Map namespaces = new HashMap<>(); try { - SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParserFactory factory = newHardenedSAXParserFactory(); SAXParser saxParser = factory.newSAXParser(); saxParser.parse(new InputSource(new StringReader(xmlContent)), new DefaultHandler() { @Override @@ -88,6 +157,7 @@ public static boolean compareXML(String actualValue, String expectedValue) { LOGGER.debug("Comparing XML:\nActual : {}\nExpected: {}", actualValue, expectedValue); Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText)) .checkForIdentical() .build(); @@ -118,6 +188,7 @@ public static boolean compareXML(String actualValue, String expectedValue, List< Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .normalizeWhitespace() .ignoreWhitespace() .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText)) @@ -150,6 +221,7 @@ public static boolean compareXMLByXPath(String actualValue, String expectedValue Map namespaces = getNamespacesFromString(expectedValue); Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .withNodeMatcher(new DefaultNodeMatcher( ElementSelectors.byXPath(xPathExpression, namespaces, ElementSelectors.byNameAndText))) .checkForIdentical() From ff1bcfdd635c9cee2479fb1b63f3ad42d6eab4bd Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 22:46:00 +0200 Subject: [PATCH 05/32] fix: enforce error taxonomy and add missing exception constructors --- .../exceptions/ComparisonException.java | 12 +++++++++ .../exceptions/ConsumerException.java | 27 +++++++++++++++++++ .../exceptions/ProducerException.java | 24 +++++++++++++++++ .../io/kafka/AbstractKafkaProducer.java | 12 +++++---- .../io/kafka/impl/AvroKafkaProducer.java | 16 ++++++----- .../io/kafka/impl/RawKafkaProducer.java | 12 +++++---- .../ktestify/match/RecordMatcherFactory.java | 14 +++++----- .../io/github/ktestify/utils/FileUtils.java | 19 ++++++------- 8 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/main/java/io/github/ktestify/exceptions/ComparisonException.java b/src/main/java/io/github/ktestify/exceptions/ComparisonException.java index 5179be1..cf6fc97 100644 --- a/src/main/java/io/github/ktestify/exceptions/ComparisonException.java +++ b/src/main/java/io/github/ktestify/exceptions/ComparisonException.java @@ -45,4 +45,16 @@ public class ComparisonException extends RuntimeException { public ComparisonException(String message) { super(message); } + + /** + * Constructs a new {@code ComparisonException} with the supplied detail message and cause. + * + * @param message a human-readable description of the comparison failure; shown directly in test reports so it + * should be as specific as possible + * @param cause the underlying exception that caused this comparison failure (e.g. an {@code IOException} from + * reading an expected file) + */ + public ComparisonException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java index c6b79e1..4c0885b 100644 --- a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java @@ -15,9 +15,36 @@ */ package io.github.ktestify.exceptions; +/** + * Thrown by the orchestration layer when a consumer operation fails. + * + *

Typically wraps a {@link FetchException} from the transport layer. Also thrown for orchestration-level + * configuration errors such as consuming from an INPUT topic or a null consumer context. + * + *

This is the only exception that test-framework adapters (Cucumber steps, Robot Framework keywords, …) + * are expected to catch and surface as a human-readable assertion failure. + * + * @since 0.3.0 + * @see FetchException + */ public class ConsumerException extends RuntimeException { + /** + * Constructs a new {@code ConsumerException} with the supplied detail message. + * + * @param message a human-readable description of the consumer failure + */ public ConsumerException(String message) { super(message); } + + /** + * Constructs a new {@code ConsumerException} with the supplied detail message and cause. + * + * @param message a human-readable description of the consumer failure + * @param cause the underlying exception that caused this failure + */ + public ConsumerException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/exceptions/ProducerException.java b/src/main/java/io/github/ktestify/exceptions/ProducerException.java index 5db3a01..2388b02 100644 --- a/src/main/java/io/github/ktestify/exceptions/ProducerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ProducerException.java @@ -15,8 +15,32 @@ */ package io.github.ktestify.exceptions; +/** + * Thrown when a Kafka producer fails to send a record — e.g. broker unreachable, serialization failure, + * interrupted during send, or schema/payload resolution error. + * + *

This is a {@link RuntimeException} so callers are not forced to declare it in their {@code throws} clause. + * + * @since 0.3.0 + */ public class ProducerException extends RuntimeException { + + /** + * Constructs a new {@code ProducerException} with the supplied detail message. + * + * @param message a human-readable description of the producer failure + */ public ProducerException(String message) { super(message); } + + /** + * Constructs a new {@code ProducerException} with the supplied detail message and cause. + * + * @param message a human-readable description of the producer failure + * @param cause the underlying exception that caused this failure + */ + public ProducerException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java index 3b2d501..80b400e 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java @@ -17,13 +17,10 @@ import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.core.AbstractProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.utils.FileUtils; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; @@ -32,6 +29,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + public abstract class AbstractKafkaProducer extends AbstractProducer { protected final Logger logger = LoggerFactory.getLogger(getClass()); @@ -114,7 +116,7 @@ protected String resolvePayload() { if (payloadFile != null) { return FileUtils.getFileContent(payloadFile); } - throw new IllegalStateException("No payload content was provided"); + throw new ProducerException("No payload content was provided"); } protected String resolveSchema() throws IOException, RestClientException { diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java index 059882b..01148d3 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java @@ -17,20 +17,22 @@ import com.google.gson.JsonObject; import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.kafka.AbstractKafkaProducer; import io.github.ktestify.io.kafka.ProducerContext; import io.github.ktestify.models.Topic; import io.github.ktestify.utils.serdes.AvroUtils; -import java.io.File; -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ExecutionException; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ExecutionException; + public class AvroKafkaProducer extends AbstractKafkaProducer { public AvroKafkaProducer( @@ -83,12 +85,12 @@ protected void produce() { metadata.timestamp()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while producing Avro message", e); + throw new ProducerException("Interrupted while producing Avro message", e); } catch (ExecutionException e) { - throw new RuntimeException("Failed to produce Avro message", e.getCause()); + throw new ProducerException("Failed to produce Avro message", e.getCause()); } catch (IOException | RestClientException e) { - throw new RuntimeException("Failed to read schema or payload for Avro message", e); + throw new ProducerException("Failed to read schema or payload for Avro message", e); } } } diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java index 41213eb..8e13303 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java @@ -15,16 +15,18 @@ */ package io.github.ktestify.io.kafka.impl; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.kafka.AbstractKafkaProducer; import io.github.ktestify.io.kafka.ProducerContext; import io.github.ktestify.models.Topic; -import java.io.File; -import java.util.Map; -import java.util.concurrent.ExecutionException; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; +import java.io.File; +import java.util.Map; +import java.util.concurrent.ExecutionException; + public class RawKafkaProducer extends AbstractKafkaProducer { public RawKafkaProducer(Topic topic, Map properties, Producer producer, File file) { @@ -68,9 +70,9 @@ protected void produce() { metadata.timestamp()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while producing raw message", e); + throw new ProducerException("Interrupted while producing raw message", e); } catch (ExecutionException e) { - throw new RuntimeException("Failed to produce raw message", e.getCause()); + throw new ProducerException("Failed to produce raw message", e.getCause()); } } } diff --git a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java index 3826b08..49d86ba 100644 --- a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java +++ b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java @@ -15,7 +15,7 @@ */ package io.github.ktestify.match; -import io.github.ktestify.exceptions.ConsumerException; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; @@ -66,11 +66,11 @@ private RecordMatcherFactory() {} * @param matchMethod one of the {@code METHOD_*} constants in {@code ConfigConstants}, or {@code null} / blank for * no-op * @return the appropriate matcher; never {@code null} - * @throws ConsumerException if the method name is non-blank but unrecognised + * @throws ConfigException if the method name is non-blank but unrecognised */ public static RecordMatcher forRaw(String matchMethod) { if (matchMethod == null || matchMethod.isBlank()) { - log.debug("No matchMethod specified — using NoOpRecordMatcher."); + log.debug("No matchMethod specified, using NoOpRecordMatcher."); return new NoOpRecordMatcher<>(); } log.debug("Resolving raw RecordMatcher for method '{}'.", matchMethod); @@ -83,7 +83,7 @@ public static RecordMatcher forRaw(String matchMethod) { case METHOD_RECORD_KEY_MATCH -> new KeyRecordMatcher(); case METHOD_MATCH_ATTRIBUTES -> new AttributeRecordMatcher<>(); default -> - throw new ConsumerException("Unknown raw matchMethod '" + matchMethod + "'. " + throw new ConfigException("Unknown raw matchMethod '" + matchMethod + "'. " + "Valid values: methodMatchFile, methodMatchKeyValue, methodFieldsToMatch, " + "methodMatchXML, methodMatchXPath, methodRecordKeyMatch, methodMatchAttributes."); }; @@ -99,11 +99,11 @@ public static RecordMatcher forRaw(String matchMethod) { * @param matchMethod one of the {@code METHOD_*} constants in {@code ConfigConstants}, or {@code null} / blank for * no-op * @return the appropriate matcher; never {@code null} - * @throws ConsumerException if the method name is non-blank but unrecognised + * @throws ConfigException if the method name is non-blank but unrecognised */ public static RecordMatcher forAvro(String matchMethod) { if (matchMethod == null || matchMethod.isBlank()) { - log.debug("No matchMethod specified — using NoOpRecordMatcher."); + log.debug("No matchMethod specified, using NoOpRecordMatcher."); return new NoOpRecordMatcher<>(); } log.debug("Resolving Avro RecordMatcher for method '{}'.", matchMethod); @@ -113,7 +113,7 @@ public static RecordMatcher forAvro(String matchMethod) { case METHOD_FIELDS_TO_MATCH -> new AvroFieldsRecordMatcher(); case METHOD_RECORD_KEY_MATCH -> new AvroKeyRecordMatcher(); default -> - throw new ConsumerException("Unknown Avro matchMethod '" + matchMethod + "'. " + throw new ConfigException("Unknown Avro matchMethod '" + matchMethod + "'. " + "Valid values: methodMatchFile, methodMatchKeyValue, " + "methodFieldsToMatch, methodRecordKeyMatch."); }; diff --git a/src/main/java/io/github/ktestify/utils/FileUtils.java b/src/main/java/io/github/ktestify/utils/FileUtils.java index d07e5a5..995e5bb 100644 --- a/src/main/java/io/github/ktestify/utils/FileUtils.java +++ b/src/main/java/io/github/ktestify/utils/FileUtils.java @@ -15,14 +15,16 @@ */ package io.github.ktestify.utils; +import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.io.inputs.DynamicVariableProcessor; -import java.io.*; -import java.nio.charset.StandardCharsets; import lombok.experimental.UtilityClass; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.*; +import java.nio.charset.StandardCharsets; + @UtilityClass public final class FileUtils { @@ -43,7 +45,7 @@ public static File getFile(String fullPath) { * * @param file the file to read * @return the content of the file as a String - * @throws RuntimeException if something went wrong + * @throws ComparisonException if the file cannot be read */ public static String getFileContent(File file) { try (InputStream inputStream = new FileInputStream(file)) { @@ -55,7 +57,7 @@ public static String getFileContent(File file) { return content; } catch (IOException e) { - throw new RuntimeException(e); + throw new ComparisonException("Failed to read file: " + file.getAbsolutePath(), e); } } @@ -64,15 +66,14 @@ public static String getFileContent(File file) { * * @param file The file for which to open the InputStream. * @return An InputStream for the specified file. - * @throws RuntimeException If an IOException occurs when opening the InputStream. + * @throws ComparisonException If an IOException occurs when opening the InputStream. */ public static InputStream getInputStream(File file) { try { return new FileInputStream(file); } catch (IOException e) { LOGGER.error("Something when wrong while getting file content from a File object : {} ", e.getMessage()); - - throw new RuntimeException(e); + throw new ComparisonException("Failed to open file input stream: " + file.getAbsolutePath(), e); } } @@ -82,7 +83,7 @@ public static InputStream getInputStream(File file) { * * @param file An InputStream to read from. * @return A string representing the content read from the InputStream. - * @throws RuntimeException If an IOException occurs during stream reading. + * @throws ComparisonException If an IOException occurs during stream reading. */ public static String getFileContent(InputStream file) { try { @@ -95,7 +96,7 @@ public static String getFileContent(InputStream file) { return content; } catch (IOException e) { LOGGER.error("Something when wrong while getting file content from a input stream : {} ", e.getMessage()); - throw new RuntimeException(e); + throw new ComparisonException("Failed to read from input stream", e); } } From d855404ed1c0e730e7993d3fb673eac2a00a47b4 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 22:51:08 +0200 Subject: [PATCH 06/32] fix: correct over-suppression of CHILD_NODELIST_LENGTH in XMLUtils --- .../io/github/ktestify/utils/XMLUtils.java | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index e5b4111..db2eb5a 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -309,22 +309,26 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome // 2. Child-count difference caused by an excluded element being present in one // document but absent in the other. XMLUnit fires CHILD_NODELIST_LENGTH on // the *parent* node, so the XPath points to the parent (e.g. /order[1]), not - // to the excluded child. We mark it SIMILAR when the count delta equals the - // number of excluded elements that appear in either document. + // to the excluded child. + // + // Without DOM access we cannot determine which excluded elements are actual + // children of this parent. We conservatively suppress the length difference + // when the delta does not exceed the total number of excluded elements. + // This is safe because XMLUnit also fires CHILD_LOOKUP (point 3 below) for + // each missing/extra child — any non-excluded child will be caught there and + // reported as a real difference. if (comparison.getType() == ComparisonType.CHILD_NODELIST_LENGTH) { Object controlVal = comparison.getControlDetails().getValue(); Object testVal = comparison.getTestDetails().getValue(); if (controlVal instanceof Integer controlCount && testVal instanceof Integer testCount) { int delta = Math.abs(controlCount - testCount); - long excludedPresent = excludedElements.stream() - .filter(el -> xPathBelongsToParent(controlXPath, el) || xPathBelongsToParent(testXPath, el)) - .count(); - if (delta <= excludedPresent) { + if (delta > 0 && delta <= excludedElements.size()) { LOGGER.debug( - "Suppressing CHILD_NODELIST_LENGTH difference — " - + "delta {} covered by {} excluded element(s).", + "Suppressing CHILD_NODELIST_LENGTH difference, " + + "delta {} within {} excluded element(s); " + + "non-excluded children will be caught by CHILD_LOOKUP.", delta, - excludedPresent); + excludedElements.size()); return ComparisonResult.SIMILAR; } } @@ -350,16 +354,5 @@ private boolean isExcluded(String xPath) { || xPath.contains("/" + el + "/") || xPath.endsWith("/" + el)); } - - /** - * Returns true if an element named {@code el} would be a direct child of the node identified by - * {@code parentXPath} (used for child-count checks). We cannot resolve the actual DOM here, so we use the - * parent XPath as a proxy — any excluded element whose name is in our list is considered a potential child. - */ - private boolean xPathBelongsToParent(String parentXPath, String el) { - // We can't walk the DOM, so we conservatively consider any excluded element - // as a potential contributor to the count difference. - return parentXPath != null && !excludedElements.isEmpty(); - } } } From 3539b500c684a2771c95a70d2a75bb08b3171191 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 23:02:04 +0200 Subject: [PATCH 07/32] fix: fixed an issue where a SchemaRegistryClient was instantiated at each call instead of being cached --- .../io/kafka/SchemaRegistryClientFactory.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java index 61bac70..056aab6 100644 --- a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java +++ b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java @@ -20,11 +20,12 @@ import io.confluent.kafka.schemaregistry.client.rest.RestService; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.config.SchemaRegistryConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.HashMap; import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Factory for creating Schema Registry clients using ktestify configuration. @@ -57,17 +58,42 @@ public final class SchemaRegistryClientFactory { private static final Logger LOG = LoggerFactory.getLogger(SchemaRegistryClientFactory.class); + /** + * Cached singleton client created from the default configuration. Reused across all callers in the same JVM + * to avoid creating a new {@link RestService} (and its HTTP connection pool) on every call. + */ + private static volatile SchemaRegistryClient cachedClient; + private SchemaRegistryClientFactory() { // Utility class } /** - * Creates a Schema Registry client using default configuration. + * Returns a cached Schema Registry client using default configuration. * - * @return a new Schema Registry client + *

The first call creates and caches the client; subsequent calls return the same instance. + * + * @return a cached Schema Registry client */ public static SchemaRegistryClient createClient() { - return createClient(KtestifyConfig.getOrLoad()); + if (cachedClient == null) { + synchronized (SchemaRegistryClientFactory.class) { + if (cachedClient == null) { + cachedClient = createClient(KtestifyConfig.getOrLoad()); + } + } + } + return cachedClient; + } + + /** + * Clears the cached singleton client. Primarily intended for test isolation so that a new config + * (e.g. via {@link KtestifyConfig#reset()}) takes effect on the next {@link #createClient()} call. + */ + public static void resetCache() { + synchronized (SchemaRegistryClientFactory.class) { + cachedClient = null; + } } /** From 651a78efa429c65c543aefd18cec65f423f1036c Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 23:14:49 +0200 Subject: [PATCH 08/32] chore: fixed some tests --- .../io/inputs/types/RandomVariable.java | 5 ++- .../io/kafka/AvroKafkaProducerTest.java | 40 +++++++------------ .../io/kafka/RawKafkaProducerTest.java | 38 ++++++------------ .../match/RecordMatcherFactoryTest.java | 18 ++++----- 4 files changed, 39 insertions(+), 62 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java index df37f75..fd790b5 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java @@ -16,9 +16,10 @@ package io.github.ktestify.io.inputs.types; import io.github.ktestify.io.inputs.DynamicVariable; +import org.apache.commons.lang3.RandomStringUtils; + import java.util.Random; import java.util.UUID; -import org.apache.commons.lang3.RandomStringUtils; public class RandomVariable implements DynamicVariable { @@ -93,6 +94,6 @@ private String generateRandomNumber(int length) { } private static String generateRandomString(int length) { - return RandomStringUtils.randomAlphanumeric(length); + return RandomStringUtils.secure().nextAlphanumeric(length); } } diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java index f40d031..0c75d06 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java @@ -15,48 +15,36 @@ */ package io.github.ktestify.io.kafka; -import static org.junit.jupiter.api.Assertions.*; - import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.serializers.KafkaAvroDeserializer; import io.confluent.kafka.serializers.KafkaAvroSerializerConfig; import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.kafka.impl.AvroKafkaProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; import io.github.ktestify.tests.extentions.SchemaRegistryTestExtension; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; -import java.util.UUID; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; -import org.apache.kafka.clients.consumer.Consumer; -import org.apache.kafka.clients.consumer.ConsumerConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.*; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + /** * Integration tests for AvroKafkaProducer using Testcontainers. * @@ -1007,7 +995,7 @@ void shouldThrowExceptionForNullPayloads() throws Exception { AvroKafkaProducer avroProducer = new AvroKafkaProducer( topic, null, producer, null, Map.of(), null, "com.example.avro.NoPayloadRecord"); - assertThrows(IllegalStateException.class, avroProducer::send); + assertThrows(ProducerException.class, avroProducer::send); } @Test diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java index 5230a87..2ad44ea 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java @@ -15,8 +15,6 @@ */ package io.github.ktestify.io.kafka; -import static org.junit.jupiter.api.Assertions.*; - import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; @@ -24,33 +22,23 @@ import io.github.ktestify.io.kafka.impl.RawKafkaProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; +import org.apache.kafka.clients.consumer.*; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; -import java.util.UUID; -import org.apache.kafka.clients.consumer.Consumer; -import org.apache.kafka.clients.consumer.ConsumerConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.consumer.KafkaConsumer; -import org.apache.kafka.clients.producer.Producer; -import org.apache.kafka.clients.producer.ProducerConfig; -import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; /** * Integration tests for RawKafkaProducer using Testcontainers. @@ -230,7 +218,7 @@ void shouldThrowExceptionForEmptyPayload() { // Then - empty string payload should throw IllegalStateException // because resolvePayload() treats empty string as no payload - assertThrows(IllegalStateException.class, rawProducer::send); + assertThrows(ProducerException.class, rawProducer::send); } } @@ -571,7 +559,7 @@ void shouldThrowExceptionWhenNoPayloadProvided() { RawKafkaProducer rawProducer = new RawKafkaProducer(context); // When/Then - assertThrows(IllegalStateException.class, rawProducer::send); + assertThrows(ProducerException.class, rawProducer::send); } } diff --git a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java index f4f69ef..176a330 100644 --- a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java +++ b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java @@ -15,10 +15,7 @@ */ package io.github.ktestify.match; -import static io.github.ktestify.match.RecordMatcherFactory.*; -import static org.junit.jupiter.api.Assertions.*; - -import io.github.ktestify.exceptions.ConsumerException; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import org.apache.avro.generic.GenericRecord; import org.junit.jupiter.api.DisplayName; @@ -28,6 +25,9 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; +import static io.github.ktestify.match.RecordMatcherFactory.*; +import static org.junit.jupiter.api.Assertions.*; + @DisplayName("RecordMatcherFactory") class RecordMatcherFactoryTest { @@ -101,7 +101,7 @@ class ForRawUnknown { @Test @DisplayName("throws ConsumerException for an unrecognised method name") void throwsForUnknown() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forRaw("methodDoesNotExist")); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forRaw("methodDoesNotExist")); } } @@ -158,25 +158,25 @@ class ForAvroUnknown { @Test @DisplayName("throws ConsumerException for an unrecognised method name") void throwsForUnknown() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); } @Test @DisplayName("throws ConsumerException for XML method (not supported for Avro)") void throwsForXml() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); } @Test @DisplayName("throws ConsumerException for XPath method (not supported for Avro)") void throwsForXPath() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); } @Test @DisplayName("throws ConsumerException for attribute method (raw transports only)") void throwsForAttributes() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_ATTRIBUTES)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_ATTRIBUTES)); } } From a0b5246716453222d7ebcfc881aca100754cfcab Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Thu, 1 Oct 2026 23:16:18 +0200 Subject: [PATCH 09/32] (feat/bug-fixes) style: spotless --- .../exceptions/ConsumerException.java | 4 ++-- .../exceptions/ProducerException.java | 4 ++-- .../io/inputs/DynamicVariableProcessor.java | 5 ++--- .../io/inputs/types/RandomVariable.java | 3 +-- .../io/kafka/AbstractKafkaProducer.java | 9 ++++---- .../io/kafka/SchemaRegistryClientFactory.java | 13 ++++++------ .../io/kafka/impl/AvroKafkaProducer.java | 9 ++++---- .../io/kafka/impl/RawKafkaProducer.java | 7 +++---- .../ktestify/plugin/PluginRegistry.java | 12 +++++------ .../io/github/ktestify/utils/FileUtils.java | 5 ++--- .../io/github/ktestify/utils/XMLUtils.java | 21 +++++++++---------- .../io/kafka/AvroKafkaProducerTest.java | 19 ++++++++--------- .../io/kafka/RawKafkaProducerTest.java | 19 ++++++++--------- .../match/RecordMatcherFactoryTest.java | 6 +++--- 14 files changed, 63 insertions(+), 73 deletions(-) diff --git a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java index 4c0885b..e314da1 100644 --- a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java @@ -21,8 +21,8 @@ *

Typically wraps a {@link FetchException} from the transport layer. Also thrown for orchestration-level * configuration errors such as consuming from an INPUT topic or a null consumer context. * - *

This is the only exception that test-framework adapters (Cucumber steps, Robot Framework keywords, …) - * are expected to catch and surface as a human-readable assertion failure. + *

This is the only exception that test-framework adapters (Cucumber steps, Robot Framework keywords, …) are expected + * to catch and surface as a human-readable assertion failure. * * @since 0.3.0 * @see FetchException diff --git a/src/main/java/io/github/ktestify/exceptions/ProducerException.java b/src/main/java/io/github/ktestify/exceptions/ProducerException.java index 2388b02..e675c80 100644 --- a/src/main/java/io/github/ktestify/exceptions/ProducerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ProducerException.java @@ -16,8 +16,8 @@ package io.github.ktestify.exceptions; /** - * Thrown when a Kafka producer fails to send a record — e.g. broker unreachable, serialization failure, - * interrupted during send, or schema/payload resolution error. + * Thrown when a Kafka producer fails to send a record — e.g. broker unreachable, serialization failure, interrupted + * during send, or schema/payload resolution error. * *

This is a {@link RuntimeException} so callers are not forced to declare it in their {@code throws} clause. * diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java index ac4e358..09a2619 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java @@ -15,11 +15,10 @@ */ package io.github.ktestify.io.inputs; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class DynamicVariableProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableProcessor.class); diff --git a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java index fd790b5..6c4776f 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java @@ -16,10 +16,9 @@ package io.github.ktestify.io.inputs.types; import io.github.ktestify.io.inputs.DynamicVariable; -import org.apache.commons.lang3.RandomStringUtils; - import java.util.Random; import java.util.UUID; +import org.apache.commons.lang3.RandomStringUtils; public class RandomVariable implements DynamicVariable { diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java index 80b400e..c6f9a68 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java @@ -21,6 +21,10 @@ import io.github.ktestify.io.core.AbstractProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.utils.FileUtils; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; @@ -29,11 +33,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; - public abstract class AbstractKafkaProducer extends AbstractProducer { protected final Logger logger = LoggerFactory.getLogger(getClass()); diff --git a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java index 056aab6..336f966 100644 --- a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java +++ b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java @@ -20,12 +20,11 @@ import io.confluent.kafka.schemaregistry.client.rest.RestService; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.config.SchemaRegistryConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.HashMap; import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Factory for creating Schema Registry clients using ktestify configuration. @@ -59,8 +58,8 @@ public final class SchemaRegistryClientFactory { private static final Logger LOG = LoggerFactory.getLogger(SchemaRegistryClientFactory.class); /** - * Cached singleton client created from the default configuration. Reused across all callers in the same JVM - * to avoid creating a new {@link RestService} (and its HTTP connection pool) on every call. + * Cached singleton client created from the default configuration. Reused across all callers in the same JVM to + * avoid creating a new {@link RestService} (and its HTTP connection pool) on every call. */ private static volatile SchemaRegistryClient cachedClient; @@ -87,8 +86,8 @@ public static SchemaRegistryClient createClient() { } /** - * Clears the cached singleton client. Primarily intended for test isolation so that a new config - * (e.g. via {@link KtestifyConfig#reset()}) takes effect on the next {@link #createClient()} call. + * Clears the cached singleton client. Primarily intended for test isolation so that a new config (e.g. via + * {@link KtestifyConfig#reset()}) takes effect on the next {@link #createClient()} call. */ public static void resetCache() { synchronized (SchemaRegistryClientFactory.class) { diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java index 01148d3..faf2246 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java @@ -22,17 +22,16 @@ import io.github.ktestify.io.kafka.ProducerContext; import io.github.ktestify.models.Topic; import io.github.ktestify.utils.serdes.AvroUtils; +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ExecutionException; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; -import java.io.File; -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ExecutionException; - public class AvroKafkaProducer extends AbstractKafkaProducer { public AvroKafkaProducer( diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java index 8e13303..2ee419f 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java @@ -19,13 +19,12 @@ import io.github.ktestify.io.kafka.AbstractKafkaProducer; import io.github.ktestify.io.kafka.ProducerContext; import io.github.ktestify.models.Topic; -import org.apache.kafka.clients.producer.Producer; -import org.apache.kafka.clients.producer.ProducerRecord; -import org.apache.kafka.clients.producer.RecordMetadata; - import java.io.File; import java.util.Map; import java.util.concurrent.ExecutionException; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; public class RawKafkaProducer extends AbstractKafkaProducer { diff --git a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java index f875f71..5c550c0 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -16,14 +16,13 @@ package io.github.ktestify.plugin; import io.github.ktestify.exceptions.PluginException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.*; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Discovers, loads, initializes, and holds all active {@link KtestifyPlugin} instances for the current JVM run. @@ -74,6 +73,7 @@ public final class PluginRegistry { /** * The {@link URLClassLoader} created for external plugin JARs, or {@code null} if no external plugins were loaded. + * *

Kept open for the lifetime of the registry because loaded plugin classes reference it. It is closed in * {@link #shutdown()} after all plugins have been shut down. */ @@ -157,8 +157,8 @@ public List getGluePackages() { } /** - * Shuts down all plugins in reverse initialization order, then closes the external plugin {@link URLClassLoader} - * if one was created. + * Shuts down all plugins in reverse initialization order, then closes the external plugin {@link URLClassLoader} if + * one was created. * *

Exceptions thrown by individual plugins are caught, logged as warnings, and swallowed so the remaining plugins * can still be shut down cleanly. @@ -210,7 +210,7 @@ private static void loadFromClasspath(PluginContext ctx, List ta * discovers plugins inside each JAR. * * @return the {@link URLClassLoader} created for the external JARs, or {@code null} if no external plugins were - * loaded (directory not configured, missing, or empty) + * loaded (directory not configured, missing, or empty) */ private static URLClassLoader loadFromDirectory(String dirPath, PluginContext ctx, List target) { if (dirPath == null || dirPath.isBlank()) { diff --git a/src/main/java/io/github/ktestify/utils/FileUtils.java b/src/main/java/io/github/ktestify/utils/FileUtils.java index 995e5bb..2175809 100644 --- a/src/main/java/io/github/ktestify/utils/FileUtils.java +++ b/src/main/java/io/github/ktestify/utils/FileUtils.java @@ -17,14 +17,13 @@ import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.io.inputs.DynamicVariableProcessor; +import java.io.*; +import java.nio.charset.StandardCharsets; import lombok.experimental.UtilityClass; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.*; -import java.nio.charset.StandardCharsets; - @UtilityClass public final class FileUtils { diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index db2eb5a..0168c76 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -15,6 +15,14 @@ */ package io.github.ktestify.utils; +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,15 +34,6 @@ import org.xmlunit.builder.DiffBuilder; import org.xmlunit.diff.*; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; -import java.io.StringReader; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. * @@ -85,8 +84,8 @@ private static SAXParserFactory newHardenedSAXParserFactory() { /** * Creates a hardened {@link DocumentBuilderFactory} with XXE protections enabled. * - *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. - * Used by XMLUnit's {@link DiffBuilder} via {@code withDocumentBuilderFactory}. + *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. Used by + * XMLUnit's {@link DiffBuilder} via {@code withDocumentBuilderFactory}. * * @return a hardened DocumentBuilderFactory */ diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java index 0c75d06..c67a81f 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java @@ -15,6 +15,8 @@ */ package io.github.ktestify.io.kafka; +import static org.junit.jupiter.api.Assertions.*; + import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.serializers.KafkaAvroDeserializer; import io.confluent.kafka.serializers.KafkaAvroSerializerConfig; @@ -25,6 +27,13 @@ import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; import io.github.ktestify.tests.extentions.SchemaRegistryTestExtension; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.kafka.clients.consumer.*; @@ -35,16 +44,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - /** * Integration tests for AvroKafkaProducer using Testcontainers. * diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java index 2ad44ea..53fce4e 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java @@ -15,6 +15,8 @@ */ package io.github.ktestify.io.kafka; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; @@ -22,14 +24,6 @@ import io.github.ktestify.io.kafka.impl.RawKafkaProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; -import org.apache.kafka.clients.consumer.*; -import org.apache.kafka.clients.producer.Producer; -import org.apache.kafka.clients.producer.ProducerConfig; -import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; - import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -37,8 +31,13 @@ import java.nio.file.Path; import java.time.Duration; import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; +import org.apache.kafka.clients.consumer.*; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * Integration tests for RawKafkaProducer using Testcontainers. diff --git a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java index 176a330..81dc9e1 100644 --- a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java +++ b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java @@ -15,6 +15,9 @@ */ package io.github.ktestify.match; +import static io.github.ktestify.match.RecordMatcherFactory.*; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import org.apache.avro.generic.GenericRecord; @@ -25,9 +28,6 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; -import static io.github.ktestify.match.RecordMatcherFactory.*; -import static org.junit.jupiter.api.Assertions.*; - @DisplayName("RecordMatcherFactory") class RecordMatcherFactoryTest { From ffcdffb2155de7218912a39af84caaca6a9dc745 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:00:45 +0200 Subject: [PATCH 10/32] fix: improve byte buffer handling in AvroDeserializer --- .../utils/serdes/AvroDeserializer.java | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java index 1023495..e76c17d 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java @@ -15,37 +15,27 @@ */ package io.github.ktestify.utils.serdes; -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import java.util.stream.Collectors; import org.apache.avro.Conversions.DecimalConversion; import org.apache.avro.Conversions.UUIDConversion; import org.apache.avro.LogicalType; import org.apache.avro.Schema; import org.apache.avro.Schema.Type; -import org.apache.avro.data.TimeConversions.DateConversion; -import org.apache.avro.data.TimeConversions.LocalTimestampMicrosConversion; -import org.apache.avro.data.TimeConversions.LocalTimestampMillisConversion; -import org.apache.avro.data.TimeConversions.TimeMicrosConversion; -import org.apache.avro.data.TimeConversions.TimeMillisConversion; -import org.apache.avro.data.TimeConversions.TimestampMicrosConversion; -import org.apache.avro.data.TimeConversions.TimestampMillisConversion; +import org.apache.avro.data.TimeConversions.*; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericFixed; import org.apache.avro.generic.GenericRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; + /** * Deserializes Avro {@link GenericRecord} instances into plain Java {@link Map} representations, handling all Avro * logical types (date, time, timestamp, decimal, UUID, …). @@ -137,12 +127,25 @@ private static Object primitiveDeserializer(Object value, Schema schema) { case ARRAY -> arrayDeserializer((Collection) value, schema); case FIXED -> ((GenericFixed) value).bytes(); case STRING -> ((CharSequence) value).toString(); - case BYTES -> ((ByteBuffer) value).array(); + case BYTES -> toByteArray((ByteBuffer) value); case INT, LONG, FLOAT, DOUBLE, BOOLEAN, NULL -> value; default -> throw new IllegalStateException(ERROR_UNEXPECTED_VALUE_TYPE + schema.getType()); }; } + /** + * Copies the readable bytes of {@code buffer} without disturbing its position. + * + *

{@link ByteBuffer#array()} is not used because it ignores the buffer's offset, position and limit, and fails + * for direct or read-only buffers. + */ + private static byte[] toByteArray(ByteBuffer buffer) { + ByteBuffer view = buffer.duplicate(); + byte[] bytes = new byte[view.remaining()]; + view.get(bytes); + return bytes; + } + private static Object unionDeserializer(Object value, Schema schema) { return objectDeserializer( value, From c2d2c7ac96f573dc1b07808c9129ddc7b9daeeba Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:02:51 +0200 Subject: [PATCH 11/32] fix: include cause in ConsumerException for better error handling --- .../ktestify/io/core/AbstractSynchronousConsumer.java | 5 +++-- .../io/github/ktestify/io/kafka/AbstractKafkaConsumer.java | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java index 9eac21d..c6fc041 100644 --- a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java +++ b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java @@ -21,9 +21,10 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; +import lombok.extern.slf4j.Slf4j; + import java.util.List; import java.util.Map; -import lombok.extern.slf4j.Slf4j; /** * Thin coordinator that wires a {@link RequestResponseClient} (transport) with a {@link RecordMatcher} (assertion) for @@ -113,7 +114,7 @@ public Boolean call() throws ConsumerException { return result.isPassed(); } catch (FetchException e) { - throw new ConsumerException(e.getMessage()); + throw new ConsumerException(e.getMessage(), e); } } } diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java index 9acb169..c474b8c 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java @@ -23,11 +23,12 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.models.Topic; -import java.util.List; -import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.Consumer; +import java.util.List; +import java.util.Map; + /** * Thin coordinator that wires a {@link KafkaRecordFetcher} (transport) with a {@link RecordMatcher} (assertion) and * exposes a single {@link #call()} entry point. @@ -119,7 +120,7 @@ public Boolean call() throws ConsumerException { return result.isPassed(); } catch (FetchException e) { - throw new ConsumerException(e.getMessage()); + throw new ConsumerException(e.getMessage(), e); } } From 27e059f484768336e0103169b4797312240d6af9 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:22:37 +0200 Subject: [PATCH 12/32] fix: handle null values in record matchers and improve logging --- .../match/impl/AvroFieldsRecordMatcher.java | 75 +++++++++++-------- .../match/impl/AvroFileKeyRecordMatcher.java | 49 ++++++------ .../match/impl/AvroFileRecordMatcher.java | 33 ++++---- .../match/impl/AvroKeyRecordMatcher.java | 16 ++-- .../match/impl/FieldsRecordMatcher.java | 68 +++++++++++------ .../match/impl/FileKeyRecordMatcher.java | 30 +++++--- .../match/impl/FileRecordMatcher.java | 16 +++- .../ktestify/match/impl/KeyRecordMatcher.java | 16 ++-- .../match/impl/NoOpRecordMatcher.java | 9 ++- .../match/impl/XPathRecordMatcher.java | 21 ++++-- .../ktestify/match/impl/XmlRecordMatcher.java | 35 +++++---- .../match/impl/MatcherTestSupport.java | 7 +- .../match/impl/XmlRecordMatcherTest.java | 11 +-- 13 files changed, 234 insertions(+), 152 deletions(-) diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java index d19e3d6..c48a99d 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java @@ -21,13 +21,13 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.avro.generic.GenericRecord; + import java.util.ArrayList; import java.util.List; import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.apache.avro.generic.GenericRecord; /** * Matches a specific field (or set of fields) within an Avro record, using either an inline expected value or an @@ -35,7 +35,9 @@ * *

Requires {@link MatchContext#getMatchKey()} to specify the JSON field name to examine. Either * {@link MatchContext#getMatchValue()} (inline) or {@link MatchContext#getMatchFilePath()} (file-based) must also be - * set. + * set. Alternatively, {@link MatchContext#getMatchKeyValues()} checks several fields at once. + * + *

A record with a {@code null} value (tombstone) fails with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -45,6 +47,9 @@ public class AvroFieldsRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } // Multi-field inline matching (keys/values columns) if (context.getMatchKeyValues() != null && !context.getMatchKeyValues().isEmpty()) { @@ -54,31 +59,41 @@ public MatchResult match(List> records, MatchConte if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroFieldsRecordMatcher requires matchKey (the field name) to be set."); } - - GenericRecord value = records.getFirst().getValue(); - String actualValue = toJson(value); String key = context.getMatchKey(); - // Option A — inline expected value + // Option A: inline expected value if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { - log.debug( - "Avro field match against inline value — key: '{}', expected: '{}'", key, context.getMatchValue()); - boolean result = AvroUtils.doesAvroValueFromKeyMatchesRecord(context.getMatchValue(), key, actualValue); - if (result) { - return MatchResult.pass(context.getMatchValue(), actualValue); + String expected = context.getMatchValue(); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected field '{}' = '{}'.", key, expected); + return MatchResult.nullValue(expected); + } + String actualValue = AvroJson.of(value); + log.debug("Avro field match against inline value, key: '{}', expected: '{}'", key, expected); + if (AvroUtils.doesAvroValueFromKeyMatchesRecord(expected, key, actualValue)) { + return MatchResult.pass(expected, actualValue); } return MatchResult.fail( - "Avro field '" + key + "' does not match expected value '" + context.getMatchValue() + "'.", - context.getMatchValue(), + "Avro field '" + key + "' does not match expected value '" + expected + "'.", + expected, actualValue); } - // Option B — field comparison against expected file + // Option B: field comparison against expected file if (context.getMatchFilePath() != null && !context.getMatchFilePath().isBlank()) { String expectedRecord = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - log.debug("Avro field match against file — key: '{}', file: '{}'", key, context.getMatchFilePath()); - boolean result = AvroUtils.doesAvroValueFromKeyMatchesRecords(key, expectedRecord, actualValue); - if (result) { + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error( + "Avro record value is null (tombstone), expected field '{}' from '{}'.", + key, + context.getMatchFilePath()); + return MatchResult.nullValue(expectedRecord); + } + String actualValue = AvroJson.of(value); + log.debug("Avro field match against file, key: '{}', file: '{}'", key, context.getMatchFilePath()); + if (AvroUtils.doesAvroValueFromKeyMatchesRecords(key, expectedRecord, actualValue)) { return MatchResult.pass(expectedRecord, actualValue); } return MatchResult.fail( @@ -93,21 +108,25 @@ public MatchResult match(List> records, MatchConte /** * Validates every key/value pair in {@link MatchContext#getMatchKeyValues()} against the actual Avro record. * - *

All pairs must match for the result to pass. The first mismatch is reported in the diff. + *

All pairs must match for the result to pass. Every mismatch is reported in the diff. * * @param records the consumed records (only the first is examined) * @param context the match context carrying the key/value pairs * @return a {@link MatchResult} indicating whether all fields matched - * @throws ComparisonException if the key/value map is empty (should not happen, guarded by the caller) + * @throws ComparisonException if a field lookup cannot be performed * @since 1.1.1 */ private MatchResult matchMultipleFields(List> records, MatchContext context) throws ComparisonException { - GenericRecord value = records.getFirst().getValue(); - String actualValue = toJson(value); Map keyValues = context.getMatchKeyValues(); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected fields {}.", keyValues); + return MatchResult.nullValue(keyValues.toString()); + } + String actualValue = AvroJson.of(value); - log.debug("Avro multi-field match against inline values : pairs: {}", keyValues); + log.debug("Avro multi-field match against inline values, pairs: {}", keyValues); List mismatches = new ArrayList<>(); for (Map.Entry entry : keyValues.entrySet()) { @@ -123,12 +142,4 @@ private MatchResult matchMultipleFields(List> reco } return MatchResult.fail(String.join(" ", mismatches), keyValues.toString(), actualValue); } - - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); - } - return AvroUtils.getPrettyAvroValue(value.toString()); - } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java index 643cd59..8e65328 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -21,23 +21,26 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; +import java.util.List; + /** * Asserts both the record key and Avro value against a configured expected key and expected JSON - * file. + * file. Supports optional field exclusion via {@link MatchContext#getExcludedFields()}, like + * {@link AvroFileRecordMatcher}. * *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()} — the expected record key - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected value JSON file + *
  • {@link MatchContext#getMatchKey()}: the expected record key + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value JSON file *
* + *

A record with a {@code null} value (tombstone) never matches; the key is still compared and reported. + * * @since 0.3.0 */ @Slf4j @@ -46,7 +49,9 @@ public class AvroFileKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroFileKeyRecordMatcher requires matchKey to be set."); } @@ -54,20 +59,22 @@ public MatchResult match(List> records, MatchConte throw new ComparisonException("AvroFileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.get(0); + ConsumedRecord record = records.getFirst(); String actualKey = record.getKey(); String expectedKey = context.getMatchKey(); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actualValue = toJson(record.getValue()); + GenericRecord value = record.getValue(); + String actualValue = value == null ? null : AvroJson.of(value); boolean keyMatches = expectedKey.equals(actualKey); - boolean valueMatches = - AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); + boolean valueMatches = actualValue != null && valueMatches(expectedValue, actualValue, context); if (!keyMatches) { - log.error("Avro key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Avro key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (!valueMatches) { + if (actualValue == null) { + log.error("Avro value mismatch: record value is null (tombstone)."); + } else if (!valueMatches) { log.error( "Avro value does not match file '{}'.\nExpected:\n{}\nActual:\n{}", context.getMatchFilePath(), @@ -79,17 +86,17 @@ public MatchResult match(List> records, MatchConte log.info("Avro record key and value both match."); return MatchResult.pass(expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } - return MatchResult.fail( - "Key match: " + keyMatches + ", value match: " + valueMatches, - expectedKey + " / " + expectedValue, - actualKey + " / " + actualValue); + String diff = "Key match: " + keyMatches + ", value match: " + valueMatches + + (actualValue == null ? " (" + MatchResult.NULL_VALUE_MESSAGE + ")" : ""); + return MatchResult.fail(diff, expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); + private static boolean valueMatches(String expectedValue, String actualValue, MatchContext context) { + List excluded = context.getExcludedFields(); + if (excluded != null && !excluded.isEmpty()) { + log.debug("Excluding Avro fields: {}", excluded); + return AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expectedValue, actualValue, excluded); } - return AvroUtils.getPrettyAvroValue(value.toString()); + return AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java index 654c451..a751c8e 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java @@ -21,17 +21,18 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; +import java.util.List; + /** * Compares an Avro record's value against the content of an expected JSON file using smart matching. Supports optional * field exclusion via {@link MatchContext#getExcludedFields()}. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -41,20 +42,22 @@ public class AvroFileRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("AvroFileRecordMatcher requires matchFilePath to be set."); } - GenericRecord value = records.get(0).getValue(); - String actualValue = toJson(value); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected content of '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expectedValue); + } + String actualValue = AvroJson.of(value); - log.debug( - "Avro file match — actual:\n{}\nExpected:\n{}", - actualValue, - AvroUtils.getPrettyAvroValue(AvroUtils.convertMapToJsonString( - AvroUtils.convertDatesToTimestamps(AvroUtils.convertJsonToMap(expectedValue))))); + log.debug("Avro file match, actual:\n{}\nExpected:\n{}", actualValue, expectedValue); boolean result; if (context.getExcludedFields() != null && !context.getExcludedFields().isEmpty()) { @@ -77,12 +80,4 @@ public MatchResult match(List> records, MatchConte return MatchResult.fail( "Avro record does not match file '" + context.getMatchFilePath() + "'.", expectedValue, actualValue); } - - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); - } - return AvroUtils.getPrettyAvroValue(value.toString()); - } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java index 152bd0c..9dfeb8e 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -20,13 +20,17 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; +import java.util.List; + /** * Asserts that the Avro record key equals the expected key in {@link MatchContext#getMatchKey()}. * + *

The record value is never inspected, so a tombstone ({@code null} value) with the expected key passes. A + * {@code null} key never matches. + * * @since 0.3.0 */ @Slf4j @@ -35,22 +39,24 @@ public class AvroKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroKeyRecordMatcher requires matchKey to be set."); } String expectedKey = context.getMatchKey(); - String actualKey = records.get(0).getKey(); + String actualKey = records.getFirst().getKey(); if (expectedKey.equals(actualKey)) { log.info("Avro record key matches expected key '{}'.", expectedKey); return MatchResult.pass(expectedKey, actualKey); } - log.error("Avro record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Avro record key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); return MatchResult.fail( - "Avro record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Avro record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + "'.", expectedKey, actualKey); } diff --git a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java index c198de9..ed5e1c1 100644 --- a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java @@ -22,16 +22,20 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FieldMatcherUtils; import io.github.ktestify.utils.FileUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * Matches a fixed-position field extracted from a positional record against either an inline expected value * ({@link MatchContext#getMatchValue()}) or the same field position in an expected file * ({@link MatchContext#getMatchFilePath()}). * *

Requires {@link MatchContext#getMatchKey()} to encode the position as {@code "line:from:to"} (e.g. - * {@code "0:10:20"} means line 0, characters 10–20). + * {@code "0:10:20"} means line 0, characters 10 to 20, end exclusive). + * + *

A record with a {@code null} value (tombstone), or one too short to contain the requested field, fails the match. + * An expected file that does not contain the field is a configuration error and throws {@link ComparisonException}. * * @since 0.3.0 */ @@ -40,42 +44,58 @@ public class FieldsRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } int[] pos = parsePosition(context); int line = pos[0]; int from = pos[1]; int to = pos[2]; + String position = "line=" + line + " from=" + from + " to=" + to; - String actualValue = records.get(0).getValue(); - String actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); + String expectedField = resolveExpectedField(context, line, from, to, position); - // Option A — compare against an inline expected value - if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { - String expectedField = context.getMatchValue(); - log.debug("Fields match: expected='{}', actual='{}'", expectedField, actualField); - if (expectedField.equals(actualField)) { - return MatchResult.pass(expectedField, actualField); - } + String actualValue = records.getFirst().getValue(); + if (actualValue == null) { + log.error("Record value is null (tombstone), expected field at {} to be '{}'.", position, expectedField); + return MatchResult.nullValue(expectedField); + } + + String actualField; + try { + actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); + } catch (IllegalArgumentException e) { + log.error("Record has no field at {}: {}", position, e.getMessage()); return MatchResult.fail( - "Field at line=" + line + " from=" + from + " to=" + to + " does not match.", - expectedField, - actualField); + "Record has no field at " + position + ": " + e.getMessage(), expectedField, actualValue); + } + + log.debug("Fields match: expected='{}', actual='{}'", expectedField, actualField); + if (expectedField.equals(actualField)) { + return MatchResult.pass(expectedField, actualField); } + return MatchResult.fail("Field at " + position + " does not match.", expectedField, actualField); + } - // Option B — compare the same field position extracted from an expected file + /** + * Resolves the expected field, either inline from {@link MatchContext#getMatchValue()} or extracted from the same + * position in the expected file. + */ + private String resolveExpectedField(MatchContext context, int line, int from, int to, String position) + throws ComparisonException { + if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { + return context.getMatchValue(); + } if (context.getMatchFilePath() != null && !context.getMatchFilePath().isBlank()) { String expectedContent = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String expectedField = FieldMatcherUtils.getFieldsToMatch(expectedContent, line, from, to); - log.debug("Fields match (from file): expected='{}', actual='{}'", expectedField, actualField); - if (expectedField.equals(actualField)) { - return MatchResult.pass(expectedField, actualField); + try { + return FieldMatcherUtils.getFieldsToMatch(expectedContent, line, from, to); + } catch (IllegalArgumentException e) { + throw new ComparisonException("Expected file '" + context.getMatchFilePath() + "' has no field at " + + position + ": " + e.getMessage()); } - return MatchResult.fail( - "Field at line=" + line + " from=" + from + " to=" + to + " does not match.", - expectedField, - actualField); } - throw new ComparisonException("FieldsRecordMatcher requires either matchValue or matchFilePath to be set."); } diff --git a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java index c7c3033..2674bf3 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -22,19 +22,22 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.StringDiffUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * Compares both the record key and value against a configured expected key and expected file content. * *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()} — expected record key - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected value file + *
  • {@link MatchContext#getMatchKey()}: the expected record key + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value file *
* + *

A record with a {@code null} value (tombstone) never matches; the key is still compared and reported. + * * @since 0.3.0 */ @Slf4j @@ -42,7 +45,9 @@ public class FileKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("FileKeyRecordMatcher requires matchKey to be set."); } @@ -50,19 +55,21 @@ public MatchResult match(List> records, MatchContext cont throw new ComparisonException("FileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.get(0); + ConsumedRecord record = records.getFirst(); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); String actualValue = record.getValue(); String expectedKey = context.getMatchKey(); String actualKey = record.getKey(); boolean keyMatches = expectedKey.equals(actualKey); - boolean valueMatches = actualValue.equals(expectedValue); + boolean valueMatches = expectedValue.equals(actualValue); if (!keyMatches) { - log.error("Key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (!valueMatches) { + if (actualValue == null) { + log.error("Value mismatch: record value is null (tombstone)."); + } else if (!valueMatches) { log.error( "Value mismatch.\nExpected diff:\n{}\nActual diff:\n{}", StringDiffUtils.getPrettyStringDiff(expectedValue, actualValue, StringDiffUtils.Type.EXPECTED), @@ -73,9 +80,8 @@ public MatchResult match(List> records, MatchContext cont log.info("Record key and value both match."); return MatchResult.pass(expectedValue, actualValue); } - return MatchResult.fail( - "Key match: " + keyMatches + ", value match: " + valueMatches, - expectedKey + " / " + expectedValue, - actualKey + " / " + actualValue); + String diff = "Key match: " + keyMatches + ", value match: " + valueMatches + + (actualValue == null ? " (" + MatchResult.NULL_VALUE_MESSAGE + ")" : ""); + return MatchResult.fail(diff, expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } } diff --git a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java index 14f6af0..34295a3 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java @@ -22,13 +22,15 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.StringDiffUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * Compares the String value of the first consumed record against the content of an expected file. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -37,14 +39,20 @@ public class FileRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("FileRecordMatcher requires matchFilePath to be set."); } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected content of '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } if (actual.equals(expected)) { log.info("Record value matches expected file '{}'.", context.getMatchFilePath()); return MatchResult.pass(expected, actual); diff --git a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java index 4a8d6e7..34b545d 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -20,12 +20,16 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * Asserts that the record key equals the expected key defined in {@link MatchContext#getMatchKey()}. * + *

The record value is never inspected, so a tombstone ({@code null} value) with the expected key passes. A + * {@code null} key never matches. + * * @since 0.3.0 */ @Slf4j @@ -33,22 +37,24 @@ public class KeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("KeyRecordMatcher requires matchKey to be set."); } String expectedKey = context.getMatchKey(); - String actualKey = records.get(0).getKey(); + String actualKey = records.getFirst().getKey(); if (expectedKey.equals(actualKey)) { log.info("Record key matches expected key '{}'.", expectedKey); return MatchResult.pass(expectedKey, actualKey); } - log.error("Record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Record key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); return MatchResult.fail( - "Record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + "'.", expectedKey, actualKey); } diff --git a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java index 72910ee..4f06ccb 100644 --- a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java @@ -19,15 +19,17 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * A {@link RecordMatcher} that always passes. * *

Use this when the test only needs to verify that a record was produced to the source (i.e. it exists), * without asserting anything about its content. It is also the safe default when no {@code matchMethod} has been - * configured. + * configured. Existence is guaranteed by the transport layer, which throws when no record arrives; this matcher never + * inspects the list, so a tombstone or an empty list both pass. * * @param the type of the record value * @since 0.3.0 @@ -38,7 +40,8 @@ public class NoOpRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) { log.debug( - "NoOpRecordMatcher: skipping assertion on {} record(s) — no match method configured.", records.size()); + "NoOpRecordMatcher: skipping assertion on {} record(s), no match method configured.", + records == null ? 0 : records.size()); return MatchResult.pass(); } } diff --git a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java index 0df5737..72cc4ae 100644 --- a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java @@ -22,9 +22,10 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.XMLUtils; -import java.util.List; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * Compares the record value as XML against an expected file using a set of XPath expressions. All XPath comparisons * must pass for the matcher to return a passing result. @@ -32,10 +33,12 @@ *

Requires: * *

    - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected XML file - *
  • {@link MatchContext#getExcludedFields()} — repurposed here as the list of XPath expressions to evaluate + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected XML file + *
  • {@link MatchContext#getExcludedFields()}: repurposed here as the list of XPath expressions to evaluate *
* + *

A record with a {@code null} value (tombstone) fails with {@link MatchResult#nullValue(String)}. + * * @since 0.3.0 */ @Slf4j @@ -43,7 +46,9 @@ public class XPathRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getExcludedFields() == null || context.getExcludedFields().isEmpty()) { throw new ComparisonException( "XPathRecordMatcher requires xPath expressions to be set via excludedFields."); @@ -53,9 +58,13 @@ public MatchResult match(List> records, MatchContext cont } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected XML from '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } log.debug( - "XPath comparison — XPaths: {}\nActual:\n{}\nExpected:\n{}", + "XPath comparison, XPaths: {}\nActual:\n{}\nExpected:\n{}", context.getExcludedFields(), actual, expected); diff --git a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java index 8b5598b..505df9c 100644 --- a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java @@ -22,26 +22,29 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.XMLUtils; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.List; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; import lombok.extern.slf4j.Slf4j; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.helpers.DefaultHandler; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + /** * Compares the record value as XML against an expected XML file. Supports optional element exclusion via * {@link MatchContext#getExcludedFields()}. * *

Additionally, any element in the expected template whose text content is exactly {@code EXCLUDED} is automatically - * added to the exclusion list. This allows a single expected file to serve multiple scenarios — scenarios only need to - * explicitly list elements they want structurally excluded; all others marked {@code EXCLUDED} in the file are + * added to the exclusion list. This allows a single expected file to serve multiple scenarios: scenarios only need to + * explicitly list elements they want structurally excluded, and all others marked {@code EXCLUDED} in the file are * suppressed as well. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. The expected template is parsed with + * {@link XMLUtils#newHardenedSAXParserFactory()} to prevent XXE. * * @since 0.3.0 */ @@ -53,14 +56,20 @@ public class XmlRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("XmlRecordMatcher requires matchFilePath to be set."); } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); - log.debug("XML comparison — actual:\n{}\nExpected:\n{}", actual, expected); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected XML from '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } + log.debug("XML comparison, actual:\n{}\nExpected:\n{}", actual, expected); // Merge explicit exclusions with any elements marked EXCLUDED in the template List effectiveExclusions = buildEffectiveExclusions(context.getExcludedFields(), expected); @@ -87,7 +96,7 @@ public MatchResult match(List> records, MatchContext cont private List buildEffectiveExclusions(List explicit, String expectedXml) { List result = new ArrayList<>(explicit != null ? explicit : List.of()); try { - SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParserFactory factory = XMLUtils.newHardenedSAXParserFactory(); SAXParser parser = factory.newSAXParser(); parser.parse(new InputSource(new StringReader(expectedXml)), new DefaultHandler() { private String currentElement; @@ -108,7 +117,7 @@ public void characters(char[] ch, int start, int length) { public void endElement(String uri, String localName, String qName) { if (EXCLUDED_SENTINEL.equals(text.toString().trim()) && !result.contains(currentElement)) { log.debug( - "Auto-excluding element '{}' — sentinel value '{}' found in template.", + "Auto-excluding element '{}': sentinel value '{}' found in template.", currentElement, EXCLUDED_SENTINEL); result.add(currentElement); diff --git a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java index 4ed40c3..efc7e30 100644 --- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java +++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java @@ -19,15 +19,16 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; +import org.apache.avro.generic.GenericRecord; + import java.net.URL; import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; -import org.apache.avro.generic.GenericRecord; /** - * Shared test helpers for {@link RecordMatcher} unit tests. Keeps each test class lean — no boilerplate record + * Shared test helpers for {@link RecordMatcher} unit tests. Keeps each test class lean: no boilerplate record * construction. */ final class MatcherTestSupport { @@ -62,7 +63,7 @@ static MatchContext ctxWithAttributes(Map expectedAttributes) { /** * Returns the absolute path of a classpath resource inside {@code src/test/resources/match/}. Throws - * {@link IllegalStateException} if the resource is not found — catches typos early. + * {@link IllegalStateException} if the resource is not found: catches typos early. */ static String resourcePath(String filename) { String path = "match/" + filename; diff --git a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java index 6a61d36..baa3eed 100644 --- a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java @@ -15,17 +15,18 @@ */ package io.github.ktestify.match.impl; -import static io.github.ktestify.match.impl.MatcherTestSupport.*; -import static org.junit.jupiter.api.Assertions.*; - import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.List; + +import static io.github.ktestify.match.impl.MatcherTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + @DisplayName("XmlRecordMatcher") class XmlRecordMatcherTest { @@ -106,7 +107,7 @@ void differentXml() throws ComparisonException { @Test @DisplayName("fails when differing element is not in the exclusion list") void failsWhenNotExcluded() throws ComparisonException { - // status differs but only orderId is excluded — should still fail + // status differs but only orderId is excluded: should still fail MatchResult result = matcher.match( rawRecord(STATUS_ONLY_DIFF_XML), ctxWithFileAndExclusions("expected-order.xml", List.of("orderId"))); From b9098258e342f5773b5ba9ea11fa514ccfb39f42 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:23:15 +0200 Subject: [PATCH 13/32] fix: enhance security in SAXParserFactory and improve documentation --- .../io/github/ktestify/utils/XMLUtils.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index 0168c76..77b4372 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -15,14 +15,6 @@ */ package io.github.ktestify.utils; -import java.io.StringReader; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,6 +26,15 @@ import org.xmlunit.builder.DiffBuilder; import org.xmlunit.diff.*; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. * @@ -48,16 +49,23 @@ public final class XMLUtils { /** * Creates a hardened {@link SAXParserFactory} with XXE protections enabled. * - *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. + *

Enables secure processing and disables external general/parameter entities, DOCTYPE declarations, and external + * DTD loading. Use this factory for every SAX parse of test data, including expected template files. * - * @return a hardened SAXParserFactory + * @return a hardened, non-namespace-aware SAXParserFactory + * @since 1.1.4 */ - private static SAXParserFactory newHardenedSAXParserFactory() { + public static SAXParserFactory newHardenedSAXParserFactory() { SAXParserFactory factory = SAXParserFactory.newInstance(); // Namespace-awareness is intentionally NOT enabled here: getNamespacesFromString // relies on xmlns:* attributes being reported as regular attributes via getQName(), // which namespace-aware mode would suppress. XXE hardening features below are // independent of this setting. + try { + factory.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not enable secure processing on SAXParserFactory: {}", e.getMessage()); + } try { factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { @@ -300,7 +308,7 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome String controlXPath = comparison.getControlDetails().getXPath(); String testXPath = comparison.getTestDetails().getXPath(); - // 1. Direct hit — the differing node IS an excluded element + // 1. Direct hit: the differing node IS an excluded element if (isExcluded(controlXPath) || isExcluded(testXPath)) { return ComparisonResult.SIMILAR; } @@ -314,7 +322,7 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome // children of this parent. We conservatively suppress the length difference // when the delta does not exceed the total number of excluded elements. // This is safe because XMLUnit also fires CHILD_LOOKUP (point 3 below) for - // each missing/extra child — any non-excluded child will be caught there and + // each missing/extra child: any non-excluded child will be caught there and // reported as a real difference. if (comparison.getType() == ComparisonType.CHILD_NODELIST_LENGTH) { Object controlVal = comparison.getControlDetails().getValue(); @@ -333,7 +341,7 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome } } - // 3. CHILD_LOOKUP — one side is null (node absent in one document) + // 3. CHILD_LOOKUP: one side is null (node absent in one document) // Check whichever side is non-null. if (comparison.getType() == ComparisonType.CHILD_LOOKUP) { String nonNullXPath = controlXPath != null ? controlXPath : testXPath; From 57c9377333cea5c3fbdd6d3b5de812d62be1ce8f Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:27:24 +0200 Subject: [PATCH 14/32] style: update comments for consistency in punctuation --- .../ktestify/config/KtestifyConfig.java | 21 ++++--- .../ktestify/config/SchemaRegistryConfig.java | 9 +-- .../ktestify/exceptions/FetchException.java | 4 +- .../ktestify/exceptions/PluginException.java | 2 +- .../exceptions/ProducerException.java | 2 +- .../exceptions/TopicMismatchException.java | 2 +- .../io/core/AbstractSynchronousConsumer.java | 2 +- .../ktestify/io/core/RecordFetcher.java | 6 +- .../io/core/RequestResponseClient.java | 4 +- .../io/kafka/AbstractKafkaConsumer.java | 6 +- .../io/kafka/impl/AvroKafkaConsumer.java | 2 +- .../io/kafka/impl/RawKafkaConsumer.java | 2 +- .../ktestify/manager/ObjectManager.java | 2 +- .../github/ktestify/match/RecordMatcher.java | 12 ++-- .../ktestify/match/RecordMatcherFactory.java | 4 +- .../ktestify/models/ConsumedRecord.java | 13 ++-- .../ktestify/plugin/KtestifyPlugin.java | 16 ++--- .../github/ktestify/plugin/PluginContext.java | 2 +- .../utils/serdes/AvroDeserializer.java | 6 +- src/main/resources/reference.conf | 2 +- .../core/AbstractSynchronousConsumerTest.java | 25 ++++---- .../io/kafka/AvroKafkaConsumerTest.java | 47 +++++++------- .../io/kafka/RawKafkaConsumerTest.java | 61 +++++++++---------- .../match/RecordMatcherFactoryTest.java | 18 +++--- .../io/github/ktestify/models/TopicTest.java | 18 +++--- .../tests/extentions/KafkaTestExtension.java | 9 +-- .../SchemaRegistryTestExtension.java | 5 +- 27 files changed, 147 insertions(+), 155 deletions(-) diff --git a/src/main/java/io/github/ktestify/config/KtestifyConfig.java b/src/main/java/io/github/ktestify/config/KtestifyConfig.java index 154f7e3..a802cb1 100644 --- a/src/main/java/io/github/ktestify/config/KtestifyConfig.java +++ b/src/main/java/io/github/ktestify/config/KtestifyConfig.java @@ -17,8 +17,6 @@ import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; -import java.io.File; -import java.util.Optional; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.Level; @@ -26,6 +24,9 @@ import org.apache.logging.log4j.core.config.Configuration; import org.apache.logging.log4j.core.config.LoggerConfig; +import java.io.File; +import java.util.Optional; + /** * Main configuration class for ktestify framework. * @@ -80,11 +81,11 @@ private KtestifyConfig(Config config) { *

Supported keys and their environment variable overrides: * *

    - *
  • {@code level} / {@code KTESTIFY_LOG_LEVEL} — {@code io.github.ktestify.*} - *
  • {@code root-level} / {@code KTESTIFY_ROOT_LOG_LEVEL} — root logger - *
  • {@code kafka-level} / {@code KTESTIFY_KAFKA_LOG_LEVEL} — {@code org.apache.kafka.*} - *
  • {@code testcontainers-level} / {@code KTESTIFY_TC_LOG_LEVEL} — Testcontainers + Docker Java - *
  • {@code confluent-level} / {@code KTESTIFY_CONFLUENT_LOG_LEVEL} — {@code io.confluent.*} + *
  • {@code level} / {@code KTESTIFY_LOG_LEVEL}: {@code io.github.ktestify.*} + *
  • {@code root-level} / {@code KTESTIFY_ROOT_LOG_LEVEL}: root logger + *
  • {@code kafka-level} / {@code KTESTIFY_KAFKA_LOG_LEVEL}: {@code org.apache.kafka.*} + *
  • {@code testcontainers-level} / {@code KTESTIFY_TC_LOG_LEVEL}: Testcontainers + Docker Java + *
  • {@code confluent-level} / {@code KTESTIFY_CONFLUENT_LOG_LEVEL}: {@code io.confluent.*} *
*/ private static void applyLogLevels(Config config) { @@ -107,7 +108,7 @@ private static void applyLogLevels(Config config) { ctx.updateLoggers(log4jConfig); log.debug( - "Log levels applied — ktestify={} root={} kafka={} confluent={}", + "Log levels applied: ktestify={} root={} kafka={} confluent={}", lc.getString("level"), lc.getString("root-level"), lc.getString("kafka-level"), @@ -145,7 +146,7 @@ private static void setLoggerLevel(Configuration log4jConfig, String loggerName, *
    *
  • {@code location} / {@code KTESTIFY_JVM_TRUSTSTORE_LOCATION} *
  • {@code password} / {@code KTESTIFY_JVM_TRUSTSTORE_PASSWORD} - *
  • {@code type} / {@code KTESTIFY_JVM_TRUSTSTORE_TYPE} — {@code JKS} or {@code PKCS12} + *
  • {@code type} / {@code KTESTIFY_JVM_TRUSTSTORE_TYPE}: {@code JKS} or {@code PKCS12} *
* * @param config the resolved Config object containing {@code ktestify.jvm.truststore} @@ -166,7 +167,7 @@ public static void applyJvmTruststore(Config config) { } log.info( - "JVM truststore applied — location={} type={}", + "JVM truststore applied: location={} type={}", location, config.getString("ktestify.jvm.truststore.type")); } diff --git a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java index 457d0f1..a919e7c 100644 --- a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java +++ b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java @@ -16,13 +16,14 @@ package io.github.ktestify.config; import com.typesafe.config.Config; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Properties; -import lombok.Getter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Schema Registry configuration. @@ -74,7 +75,7 @@ public final class SchemaRegistryConfig { this.sslKeystorePassword = getOptionalString(sslConfig, "keystore-password"); LOG.debug( - "SchemaRegistryConfig loaded — url={}, autoRegister={}, auth.credentialsSource={}, auth.userInfo={}", + "SchemaRegistryConfig loaded: url={}, autoRegister={}, auth.credentialsSource={}, auth.userInfo={}", this.url, this.autoRegisterSchemas, this.basicAuthCredentialsSource.orElse(""), diff --git a/src/main/java/io/github/ktestify/exceptions/FetchException.java b/src/main/java/io/github/ktestify/exceptions/FetchException.java index 6692602..546365f 100644 --- a/src/main/java/io/github/ktestify/exceptions/FetchException.java +++ b/src/main/java/io/github/ktestify/exceptions/FetchException.java @@ -19,8 +19,8 @@ * Thrown when a {@code RecordFetcher} fails to retrieve records from an IO source (timeout, connectivity issue, * authentication failure, etc.). * - *

This exception is transport-agnostic — Kafka, IBM MQ, and any future IO adapter all throw {@code FetchException} - * so higher layers do not need to catch transport-specific exceptions. + *

This exception is transport-agnostic: Kafka, IBM MQ, and any future IO adapter all throw {@code FetchException} so + * higher layers do not need to catch transport-specific exceptions. * * @since 0.3.0 */ diff --git a/src/main/java/io/github/ktestify/exceptions/PluginException.java b/src/main/java/io/github/ktestify/exceptions/PluginException.java index 761ec96..59c5c2e 100644 --- a/src/main/java/io/github/ktestify/exceptions/PluginException.java +++ b/src/main/java/io/github/ktestify/exceptions/PluginException.java @@ -19,7 +19,7 @@ * Thrown when a {@link io.github.ktestify.plugin.KtestifyPlugin} fails to load or initialize. * *

A {@code PluginException} during - * {@link io.github.ktestify.plugin.PluginRegistry#load(io.github.ktestify.plugin.PluginContext)} is fatal — the run is + * {@link io.github.ktestify.plugin.PluginRegistry#load(io.github.ktestify.plugin.PluginContext)} is fatal: the run is * aborted immediately. This prevents silent partial initialization where some plugins are active and others are not. * * @since 1.1.0 diff --git a/src/main/java/io/github/ktestify/exceptions/ProducerException.java b/src/main/java/io/github/ktestify/exceptions/ProducerException.java index e675c80..ec7cedc 100644 --- a/src/main/java/io/github/ktestify/exceptions/ProducerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ProducerException.java @@ -16,7 +16,7 @@ package io.github.ktestify.exceptions; /** - * Thrown when a Kafka producer fails to send a record — e.g. broker unreachable, serialization failure, interrupted + * Thrown when a Kafka producer fails to send a record: e.g. broker unreachable, serialization failure, interrupted * during send, or schema/payload resolution error. * *

This is a {@link RuntimeException} so callers are not forced to declare it in their {@code throws} clause. diff --git a/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java b/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java index 188352a..14f459f 100644 --- a/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java +++ b/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java @@ -22,7 +22,7 @@ * call) resolves more than one distinct topic, where exactly one is required. * *

This is a guard-rail exception: a DataTable listing several instructions is only allowed to target a single topic - * per call. Mixing topics in one DataTable is almost always an authoring mistake — split it into separate step + * per call. Mixing topics in one DataTable is almost always an authoring mistake: split it into separate step * invocations instead. * * @since 0.4.0 diff --git a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java index c6fc041..fa0c033 100644 --- a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java +++ b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java @@ -94,7 +94,7 @@ protected AbstractSynchronousConsumer( * *

    *
  1. Build the request via {@link #buildRequest()}. - *
  2. Call {@link RequestResponseClient#execute(Object)} — blocks until the response arrives or fails. + *
  3. Call {@link RequestResponseClient#execute(Object)}: blocks until the response arrives or fails. *
  4. Pass the resulting records to {@link RecordMatcher#match(List, MatchContext)}. *
* diff --git a/src/main/java/io/github/ktestify/io/core/RecordFetcher.java b/src/main/java/io/github/ktestify/io/core/RecordFetcher.java index 5200689..22d80d6 100644 --- a/src/main/java/io/github/ktestify/io/core/RecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/core/RecordFetcher.java @@ -25,8 +25,8 @@ *

Implementations exist per transport: * *

    - *
  • {@code KafkaRecordFetcher} — Apache Kafka - *
  • {@code IbmMqRecordFetcher} — IBM MQ (future) + *
  • {@code KafkaRecordFetcher}: Apache Kafka + *
  • {@code IbmMqRecordFetcher}: IBM MQ (future) *
* *

The return type {@link ConsumedRecord} is the common currency shared between the transport layer and the assertion @@ -48,7 +48,7 @@ public interface RecordFetcher extends AutoCloseable { List> fetch() throws FetchException; /** - * Releases all resources held by this fetcher (connections, threads, etc.). Idempotent — calling {@code close()} + * Releases all resources held by this fetcher (connections, threads, etc.). Idempotent: calling {@code close()} * more than once must be safe. */ @Override diff --git a/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java b/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java index 4dc4c31..bce4e88 100644 --- a/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java +++ b/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java @@ -32,8 +32,8 @@ *

Implementations exist per transport: * *

    - *
  • {@code HttpRequestResponseClient} — HTTP / HTTPS (ktestify-plugin-http) - *
  • {@code GrpcRequestResponseClient} — gRPC (future) + *
  • {@code HttpRequestResponseClient}: HTTP / HTTPS (ktestify-plugin-http) + *
  • {@code GrpcRequestResponseClient}: gRPC (future) *
* * @param the request type specific to the transport (e.g. an HTTP request spec) diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java index c474b8c..ca7d312 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java @@ -37,8 +37,8 @@ * responsibilities belong exclusively to their respective collaborators. The only decision made here is: fetch → match * → return result. * - *

Concrete subclasses only need to supply a {@link RecordMatcher} — typically resolved by - * {@code RecordMatcherFactory} — and call the {@link #AbstractKafkaConsumer(ConsumerContext, RecordMatcher)} + *

Concrete subclasses only need to supply a {@link RecordMatcher}: typically resolved by + * {@code RecordMatcherFactory}: and call the {@link #AbstractKafkaConsumer(ConsumerContext, RecordMatcher)} * constructor. * * @param the Kafka record key type @@ -96,7 +96,7 @@ protected AbstractKafkaConsumer( * *

    *
  1. Create a {@link KafkaRecordFetcher} for this invocation. - *
  2. Call {@link KafkaRecordFetcher#fetch()} — blocks until records arrive or timeout. + *
  3. Call {@link KafkaRecordFetcher#fetch()}: blocks until records arrive or timeout. *
  4. Pass the fetched records to {@link RecordMatcher#match(List, MatchContext)}. *
  5. Close the fetcher unconditionally in a {@code finally} block. *
diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java index 4975276..17b8044 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java @@ -28,7 +28,7 @@ *

Delegates all Kafka mechanics to {@link io.github.ktestify.io.kafka.KafkaRecordFetcher} and all assertion logic to * the {@link RecordMatcher} resolved by {@link RecordMatcherFactory#forAvro(String)}. * - *

Typical usage — consume and match against an expected JSON file: + *

Typical usage: consume and match against an expected JSON file: * *

  * Consumer<String, GenericRecord> kafkaConsumer = KafkaClientFactory.createAvroConsumer();
diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
index 1c166cf..d22e4ad 100644
--- a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
+++ b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
@@ -28,7 +28,7 @@
  * mechanics to {@link io.github.ktestify.io.kafka.KafkaRecordFetcher} and all assertion logic to the supplied
  * {@link RecordMatcher}.
  *
- * 

Typical usage — consume and assert against a file: + *

Typical usage: consume and assert against a file: * *

  * Consumer<String, String> kafkaConsumer = KafkaClientFactory.createRawConsumer();
diff --git a/src/main/java/io/github/ktestify/manager/ObjectManager.java b/src/main/java/io/github/ktestify/manager/ObjectManager.java
index dfaddfd..b662fad 100644
--- a/src/main/java/io/github/ktestify/manager/ObjectManager.java
+++ b/src/main/java/io/github/ktestify/manager/ObjectManager.java
@@ -47,7 +47,7 @@ public void register(String name, T object) {
      * used to retrieve it.
      *
      * @param name the canonical name
-     * @param alias the alias (may be {@code null} or blank — ignored if so)
+     * @param alias the alias (may be {@code null} or blank: ignored if so)
      * @param object the object to store
      */
     public void register(String name, String alias, T object) {
diff --git a/src/main/java/io/github/ktestify/match/RecordMatcher.java b/src/main/java/io/github/ktestify/match/RecordMatcher.java
index 3415204..3c924ff 100644
--- a/src/main/java/io/github/ktestify/match/RecordMatcher.java
+++ b/src/main/java/io/github/ktestify/match/RecordMatcher.java
@@ -27,16 +27,16 @@
  * {@link MatchContext}.
  *
  * 

Implementations have zero dependency on Kafka, IBM MQ, or any other transport. They only know - * about {@link ConsumedRecord} — the common currency produced by every fetcher. This means every matcher works - * unchanged for Kafka today and IBM MQ tomorrow. + * about {@link ConsumedRecord}: the common currency produced by every fetcher. This means every matcher works unchanged + * for Kafka today and IBM MQ tomorrow. * *

Concrete implementations live in {@code io.github.ktestify.match.impl}: * *

    - *
  • {@code NoOpRecordMatcher} — always passes; use when only consumption matters - *
  • {@code FileRecordMatcher} — compares record value against a file (String diff) - *
  • {@code JsonRecordMatcher} — structural JSON comparison with excluded-field support - *
  • {@code AvroRecordMatcher} — delegates to {@code AvroUtils} for Avro records + *
  • {@code NoOpRecordMatcher}: always passes; use when only consumption matters + *
  • {@code FileRecordMatcher}: compares record value against a file (String diff) + *
  • {@code JsonRecordMatcher}: structural JSON comparison with excluded-field support + *
  • {@code AvroRecordMatcher}: delegates to {@code AvroUtils} for Avro records *
* * @param the type of the record value (e.g. {@code String}, {@code GenericRecord}) diff --git a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java index 49d86ba..58a7dea 100644 --- a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java +++ b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java @@ -27,8 +27,8 @@ *

Two typed factory methods are provided so the compiler can enforce type safety: * *

    - *
  • {@link #forRaw(String)} — for {@code String}-valued topics - *
  • {@link #forAvro(String)} — for {@code GenericRecord}-valued (Avro) topics + *
  • {@link #forRaw(String)}: for {@code String}-valued topics + *
  • {@link #forAvro(String)}: for {@code GenericRecord}-valued (Avro) topics *
* *

When {@code matchMethod} is {@code null} or blank a {@link NoOpRecordMatcher} is returned, making "consume-only" diff --git a/src/main/java/io/github/ktestify/models/ConsumedRecord.java b/src/main/java/io/github/ktestify/models/ConsumedRecord.java index fd6dcd0..d865ce3 100644 --- a/src/main/java/io/github/ktestify/models/ConsumedRecord.java +++ b/src/main/java/io/github/ktestify/models/ConsumedRecord.java @@ -15,20 +15,21 @@ */ package io.github.ktestify.models; -import java.time.Instant; -import java.util.Collections; -import java.util.Map; import lombok.Builder; import lombok.Value; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.common.header.Header; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; + /** * Immutable value object representing a single record that has been fetched from any IO source (Kafka, IBM MQ, etc.). * *

This is the common currency that flows between the transport layer ({@code RecordFetcher} and * {@code RequestResponseClient}) and the assertion layer ({@code RecordMatcher}). Matchers have zero dependency on - * Kafka or any other transport — they only know about {@code ConsumedRecord}. + * Kafka or any other transport: they only know about {@code ConsumedRecord}. * *

Synchronous transports (see {@code RequestResponseClient}) additionally populate {@link #attributes} with * structured transport metadata such as an HTTP status code. Asynchronous transports leave it empty. @@ -42,10 +43,10 @@ public class ConsumedRecord { /** The source topic / queue / channel name. */ String source; - /** Partition index — 0 for non-partitioned sources (e.g. IBM MQ). */ + /** Partition index: 0 for non-partitioned sources (e.g. IBM MQ). */ int partition; - /** Offset within the partition — {@code -1} if the source has no offset concept. */ + /** Offset within the partition: {@code -1} if the source has no offset concept. */ long offset; /** Record key as a String. May be {@code null} if the source has no key concept. */ diff --git a/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java b/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java index b7f85a0..42dad64 100644 --- a/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java +++ b/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java @@ -18,7 +18,7 @@ /** * Service Provider Interface (SPI) for ktestify plugins. * - *

A plugin extends ktestify with new capabilities — typically a new transport (e.g. Azure Blob Storage, IBM MQ, + *

A plugin extends ktestify with new capabilities: typically a new transport (e.g. Azure Blob Storage, IBM MQ, * Amazon S3) plus a set of Cucumber step definitions that exercise it. * *

How to implement a plugin

@@ -45,9 +45,9 @@ *

Lifecycle

* *
    - *
  1. {@link #initialize(PluginContext)} — called once at JVM startup, before any Cucumber scenario runs. + *
  2. {@link #initialize(PluginContext)}: called once at JVM startup, before any Cucumber scenario runs. *
  3. Plugin steps execute normally during scenario runs. - *
  4. {@link #shutdown()} — called once on JVM shutdown. + *
  5. {@link #shutdown()}: called once on JVM shutdown. *
* * @since 1.1.0 @@ -61,7 +61,7 @@ public interface KtestifyPlugin { * *

This ID is used in log messages and as the HOCON config subtree key ({@code ktestify.plugins.}). * - * @return the plugin identifier — never {@code null} or blank + * @return the plugin identifier: never {@code null} or blank */ String getId(); @@ -78,7 +78,7 @@ public interface KtestifyPlugin { *

Shown in the ktestify startup banner alongside the plugin ID and version so operators can immediately identify * who is responsible for each loaded plugin. Override this in your implementation to provide a real name. * - * @return the author name — {@code "unknown"} by default + * @return the author name: {@code "unknown"} by default */ default String getAuthorName() { return "unknown"; @@ -90,7 +90,7 @@ default String getAuthorName() { *

Displayed next to {@link #getAuthorName()} in the plugin registry startup log. Override this to provide a real * email address. * - * @return the author email — empty string by default + * @return the author email: empty string by default */ default String getAuthorEmail() { return ""; @@ -101,7 +101,7 @@ default String getAuthorEmail() { * {@code "io.github.ktestify.azureblob.steps"}). * *

The ktestify runtime injects this package as a {@code --glue} argument to the Cucumber CLI so step definitions - * are discovered automatically — no manual configuration required. + * are discovered automatically: no manual configuration required. * *

Return {@code null} or an empty string if the plugin does not contribute any step definitions. * @@ -123,7 +123,7 @@ default String getAuthorEmail() { /** * Shuts down the plugin. Called once on JVM shutdown, after all Cucumber scenarios have finished. * - *

Implementations should release all resources (connections, thread pools, etc.) and must not throw exceptions — + *

Implementations should release all resources (connections, thread pools, etc.) and must not throw exceptions: * log and swallow instead. */ void shutdown(); diff --git a/src/main/java/io/github/ktestify/plugin/PluginContext.java b/src/main/java/io/github/ktestify/plugin/PluginContext.java index 43e6227..d1fcc0a 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginContext.java +++ b/src/main/java/io/github/ktestify/plugin/PluginContext.java @@ -47,7 +47,7 @@ public interface PluginContext { * {@code ktestify.plugins.} subtree. The full config object is provided (rather than a pre-sliced subtree) so * plugins can also access shared settings (e.g. {@code ktestify.framework.directories.assets}) when needed. * - * @return the loaded framework configuration — never {@code null} + * @return the loaded framework configuration: never {@code null} */ KtestifyConfig getConfig(); } diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java index e76c17d..29c23a0 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java @@ -78,7 +78,7 @@ private AvroDeserializer() {} /** Deserializes a {@link GenericRecord} into an ordered {@link Map}, preserving schema field order. */ public static Map recordDeserializer(GenericRecord record) { if (record.getSchema() == null || record.getSchema().getFields() == null) { - LOGGER.error("Record schema or fields are null — returning empty map."); + LOGGER.error("Record schema or fields are null: returning empty map."); return new HashMap<>(); } return record.getSchema().getFields().stream() @@ -114,7 +114,7 @@ public static Object objectDeserializer(Object value, Schema schema) { } // ========================================================================= - // Private — primitive / compound + // Private: primitive / compound // ========================================================================= @SuppressWarnings("unchecked") @@ -175,7 +175,7 @@ private static Collection arrayDeserializer(Collection value, Schema schem } // ========================================================================= - // Private — logical types + // Private: logical types // ========================================================================= private static Instant timestampMicrosDeserializer(Object v, Schema s, Type t, LogicalType l) { diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 97d662c..19835b3 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -177,7 +177,7 @@ ktestify { # # Each key also has a corresponding environment variable override. logging { - # io.github.ktestify.* — the framework itself + # io.github.ktestify.*: the framework itself level = "DEBUG" level = ${?KTESTIFY_LOG_LEVEL} diff --git a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java index fe196b4..1f85f09 100644 --- a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java @@ -15,26 +15,27 @@ */ package io.github.ktestify.io.core; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - import io.github.ktestify.exceptions.ConsumerException; import io.github.ktestify.exceptions.FetchException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + @DisplayName("AbstractSynchronousConsumer") class AbstractSynchronousConsumerTest { @@ -61,7 +62,7 @@ void setUp() { // ========================================================================= @Nested - @DisplayName("call — success") + @DisplayName("call: success") class Success { @Test @@ -97,7 +98,7 @@ void wiresRequestAndContext() { } @Test - @DisplayName("does not close the client — the client outlives a single call") + @DisplayName("does not close the client: the client outlives a single call") void doesNotCloseClient() { when(client.execute(anyString())).thenReturn(records()); when(matcher.match(any(), any())).thenReturn(MatchResult.pass()); @@ -113,7 +114,7 @@ void doesNotCloseClient() { // ========================================================================= @Nested - @DisplayName("call — failure") + @DisplayName("call: failure") class Failure { @Test diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java index bba940e..5a47eac 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java @@ -15,9 +15,6 @@ */ package io.github.ktestify.io.kafka; -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; -import static org.junit.jupiter.api.Assertions.*; - import io.confluent.kafka.serializers.KafkaAvroDeserializer; import io.confluent.kafka.serializers.KafkaAvroSerializer; import io.confluent.kafka.serializers.KafkaAvroSerializerConfig; @@ -28,10 +25,6 @@ import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; import io.github.ktestify.tests.extentions.SchemaRegistryTestExtension; -import java.net.URL; -import java.util.List; -import java.util.Properties; -import java.util.UUID; import org.apache.avro.Schema; import org.apache.avro.SchemaBuilder; import org.apache.avro.generic.GenericData; @@ -43,27 +36,29 @@ import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; +import java.net.URL; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; +import static org.junit.jupiter.api.Assertions.*; + /** * Integration tests for {@link AvroKafkaConsumer}. * *

Infrastructure: * *

    - *
  • {@link KafkaTestExtension} — Kafka broker via Testcontainers - *
  • {@link SchemaRegistryTestExtension} — Confluent Schema Registry via Testcontainers + *
  • {@link KafkaTestExtension}: Kafka broker via Testcontainers + *
  • {@link SchemaRegistryTestExtension}: Confluent Schema Registry via Testcontainers *
* - *

Records are seeded via a plain {@link KafkaProducer} with {@link KafkaAvroSerializer} — NOT the project's - * {@code AvroKafkaProducer} — keeping the consumer under test fully isolated from the producer implementation. + *

Records are seeded via a plain {@link KafkaProducer} with {@link KafkaAvroSerializer}: NOT the project's + * {@code AvroKafkaProducer}: keeping the consumer under test fully isolated from the producer implementation. * *

{@link KafkaRecordFetcher#clearMatchedRecords()} is called before every test to prevent deduplication state * leaking between tests. @@ -75,7 +70,7 @@ class AvroKafkaConsumerTest { private static final String TOPIC_PREFIX = "test-avro-consumer-"; // ------------------------------------------------------------------------- - // Shared Avro schema — same shape as expected-order.json + // Shared Avro schema: same shape as expected-order.json // ------------------------------------------------------------------------- static final Schema ORDER_SCHEMA = SchemaBuilder.record("Order") .namespace("io.github.ktestify.test") @@ -204,7 +199,7 @@ private org.apache.kafka.clients.consumer.Consumer create // ========================================================================= @Nested - @DisplayName("Consume-only — no matcher") + @DisplayName("Consume-only: no matcher") class ConsumeOnly { @Test @@ -231,11 +226,11 @@ void throwsWhenTopicEmpty() { } // ========================================================================= - // File matching — AvroFileRecordMatcher + // File matching: AvroFileRecordMatcher // ========================================================================= @Nested - @DisplayName("File matching — METHOD_MATCH_FILE") + @DisplayName("File matching: METHOD_MATCH_FILE") class FileMatching { @Test @@ -280,7 +275,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter — expectedRecordKey") + @DisplayName("Key filter: expectedRecordKey") class KeyFilter { @Test @@ -307,7 +302,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("Batch consumption — isBatchConsumer(true)") + @DisplayName("Batch consumption: isBatchConsumer(true)") class BatchConsumption { /** 4 orders with distinct keys and field values. */ @@ -376,7 +371,7 @@ void throwsWhenNotEnoughRecords() throws Exception { } @Test - @DisplayName("all 4 Avro records are registered as matched — a second consumer finds nothing") + @DisplayName("all 4 Avro records are registered as matched: a second consumer finds nothing") void batchRecordsAreDeduplicated() throws Exception { seedBatch(); @@ -391,7 +386,7 @@ void batchRecordsAreDeduplicated() throws Exception { .build()) .call(); - // Second consumer — all records already matched, must time out + // Second consumer: all records already matched, must time out assertThrows( ConsumerException.class, () -> new AvroKafkaConsumer(ConsumerContext.builder() diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java index 95bc220..59897ff 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java @@ -15,39 +15,34 @@ */ package io.github.ktestify.io.kafka; -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_XML; -import static org.junit.jupiter.api.Assertions.*; - import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConsumerException; import io.github.ktestify.io.kafka.impl.RawKafkaConsumer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Properties; -import java.util.UUID; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringSerializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_XML; +import static org.junit.jupiter.api.Assertions.*; + /** * Integration tests for {@link RawKafkaConsumer}. * - *

Kafka broker is provided by {@link KafkaTestExtension}. Records are seeded via a plain {@link KafkaProducer} — NOT - * the project's RawKafkaProducer — keeping the consumer under test fully isolated from the producer implementation. + *

Kafka broker is provided by {@link KafkaTestExtension}. Records are seeded via a plain {@link KafkaProducer}: NOT + * the project's RawKafkaProducer: keeping the consumer under test fully isolated from the producer implementation. * *

Every test gets a fresh, uniquely-named topic and {@link KafkaRecordFetcher#clearMatchedRecords()} is called * before each test to prevent deduplication state leaking between tests. @@ -157,7 +152,7 @@ private ConsumerContext ctx() { // ========================================================================= @Nested - @DisplayName("Consume-only — no matcher") + @DisplayName("Consume-only: no matcher") class ConsumeOnly { @Test @@ -172,7 +167,7 @@ void returnsTrueWhenRecordPresent() throws Exception { @Test @DisplayName("throws ConsumerException when topic is empty and timeout elapses") void throwsWhenTopicEmpty() { - // Do NOT seed — consumer must time out + // Do NOT seed: consumer must time out ConsumerContext ctx = ConsumerContext.builder() .topic(outputTopic()) .consumer(KafkaClientFactory.createRawConsumer( @@ -190,7 +185,7 @@ void throwsWhenTopicEmpty() { // ========================================================================= @Nested - @DisplayName("File matching — METHOD_MATCH_FILE") + @DisplayName("File matching: METHOD_MATCH_FILE") class FileMatching { @Test @@ -245,7 +240,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter — expectedRecordKey") + @DisplayName("Key filter: expectedRecordKey") class KeyFilter { @Test @@ -273,7 +268,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("XML matching — METHOD_MATCH_XML") + @DisplayName("XML matching: METHOD_MATCH_XML") class XmlMatching { @Test @@ -301,7 +296,7 @@ void matchesXml() throws Exception { // ========================================================================= @Nested - @DisplayName("Batch consumption — isBatchConsumer(true)") + @DisplayName("Batch consumption: isBatchConsumer(true)") class BatchConsumption { /** The 4 payloads seeded in every test in this nested class. */ @@ -350,7 +345,7 @@ void throwsWhenNotEnoughRecords() throws Exception { .topic(outputTopic()) .consumer(KafkaClientFactory.createRawConsumer( KtestifyConfig.getOrLoad(), "raw-batch-short-" + UUID.randomUUID())) - .readTimeout(3_000L) // short timeout — we expect a failure + .readTimeout(3_000L) // short timeout: we expect a failure .consumerDeltaTime(60_000L) .isBatchConsumer(true) .batchSize(4) @@ -359,7 +354,7 @@ void throwsWhenNotEnoughRecords() throws Exception { } @Test - @DisplayName("all 4 records are registered as matched — a second consumer finds nothing") + @DisplayName("all 4 records are registered as matched: a second consumer finds nothing") void batchRecordsAreDeduplicated() throws Exception { seedBatch(); @@ -375,7 +370,7 @@ void batchRecordsAreDeduplicated() throws Exception { .build()) .call(); - // Second consumer — all records already matched, so it must time out + // Second consumer: all records already matched, so it must time out assertThrows( ConsumerException.class, () -> new RawKafkaConsumer(ConsumerContext.builder() @@ -392,12 +387,12 @@ void batchRecordsAreDeduplicated() throws Exception { } // ========================================================================= - // referenceTimestamp — pinned "now" fixes clock-drift across delayed fetches + // referenceTimestamp: pinned "now" fixes clock-drift across delayed fetches // (see https://github.com/ktestify/ktestify-cucumber/issues/38) // ========================================================================= @Nested - @DisplayName("referenceTimestamp — pinned 'now' avoids clock drift") + @DisplayName("referenceTimestamp: pinned 'now' avoids clock drift") class ReferenceTimestamp { /** Narrow enough that a few seconds of drift pushes the seek window past the seeded record. */ @@ -412,7 +407,7 @@ void recordIsMissedDueToClockDriftWithoutReferenceTimestamp() throws Exception { seedRecord("KEY-1", "{\"orderId\":\"ORD-DRIFT\"}"); // Simulate the delay a slow Cucumber step (or a previous DataTable row) would introduce - // before this consumer actually seeks — this is exactly the drift the maintainer described + // before this consumer actually seeks: this is exactly the drift the maintainer described // in issue #38. Thread.sleep(SIMULATED_STEP_DELAY_MS); @@ -422,7 +417,7 @@ void recordIsMissedDueToClockDriftWithoutReferenceTimestamp() throws Exception { KtestifyConfig.getOrLoad(), "drift-consumer-" + UUID.randomUUID())) .readTimeout(3_000L) .consumerDeltaTime(NARROW_DELTA_TIME_MS) - // No referenceTimestamp — "now" is resolved live, at seek time. + // No referenceTimestamp: "now" is resolved live, at seek time. .build(); assertThrows( @@ -467,7 +462,7 @@ void sequentialFetchesShareSameSeekWindow() throws Exception { seedRecord("KEY-1", "{\"orderId\":\"ORD-A\"}"); seedRecord("KEY-2", "{\"orderId\":\"ORD-B\"}"); - // First "row" — simulate a small delay before it runs. + // First "row": simulate a small delay before it runs. Thread.sleep(1_500L); boolean firstResult = new RawKafkaConsumer(ConsumerContext.builder() .topic(outputTopic()) @@ -480,7 +475,7 @@ void sequentialFetchesShareSameSeekWindow() throws Exception { .build()) .call(); - // Second "row" — additional delay elapses before it runs too. + // Second "row": additional delay elapses before it runs too. Thread.sleep(1_500L); boolean secondResult = new RawKafkaConsumer(ConsumerContext.builder() .topic(outputTopic()) diff --git a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java index 81dc9e1..2989b45 100644 --- a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java +++ b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java @@ -15,9 +15,6 @@ */ package io.github.ktestify.match; -import static io.github.ktestify.match.RecordMatcherFactory.*; -import static org.junit.jupiter.api.Assertions.*; - import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import org.apache.avro.generic.GenericRecord; @@ -28,6 +25,9 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; +import static io.github.ktestify.match.RecordMatcherFactory.*; +import static org.junit.jupiter.api.Assertions.*; + @DisplayName("RecordMatcherFactory") class RecordMatcherFactoryTest { @@ -36,7 +36,7 @@ class RecordMatcherFactoryTest { // ========================================================================= @Nested - @DisplayName("forRaw — known methods") + @DisplayName("forRaw: known methods") class ForRawKnownMethods { @Test @@ -83,7 +83,7 @@ void matchAttributes() { } @Nested - @DisplayName("forRaw — null/blank → NoOpRecordMatcher") + @DisplayName("forRaw: null/blank → NoOpRecordMatcher") class ForRawNullBlank { @ParameterizedTest(name = "forRaw(\"{0}\") → NoOpRecordMatcher") @@ -95,7 +95,7 @@ void returnsNoOp(String method) { } @Nested - @DisplayName("forRaw — unknown method → ConsumerException") + @DisplayName("forRaw: unknown method → ConsumerException") class ForRawUnknown { @Test @@ -110,7 +110,7 @@ void throwsForUnknown() { // ========================================================================= @Nested - @DisplayName("forAvro — known methods") + @DisplayName("forAvro: known methods") class ForAvroKnownMethods { @Test @@ -139,7 +139,7 @@ void matchKey() { } @Nested - @DisplayName("forAvro — null/blank → NoOpRecordMatcher") + @DisplayName("forAvro: null/blank → NoOpRecordMatcher") class ForAvroNullBlank { @ParameterizedTest(name = "forAvro(\"{0}\") → NoOpRecordMatcher") @@ -152,7 +152,7 @@ void returnsNoOp(String method) { } @Nested - @DisplayName("forAvro — unknown method → ConsumerException") + @DisplayName("forAvro: unknown method → ConsumerException") class ForAvroUnknown { @Test diff --git a/src/test/java/io/github/ktestify/models/TopicTest.java b/src/test/java/io/github/ktestify/models/TopicTest.java index 91057dd..097793a 100644 --- a/src/test/java/io/github/ktestify/models/TopicTest.java +++ b/src/test/java/io/github/ktestify/models/TopicTest.java @@ -15,19 +15,15 @@ */ package io.github.ktestify.models; -import static org.junit.jupiter.api.Assertions.*; - import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.*; /** - * Unit tests for {@link Topic#validateTopic(Topic)} — focusing on the namespace auto-injection logic: + * Unit tests for {@link Topic#validateTopic(Topic)}: focusing on the namespace auto-injection logic: * *

  * if (topic.getTopicNamespace() == null || blank)
@@ -35,7 +31,7 @@
  * → inject the config namespace
  * 
*/ -@DisplayName("Topic.validateTopic — namespace auto-injection") +@DisplayName("Topic.validateTopic: namespace auto-injection") class TopicTest { // ----------------------------------------------------------------------- @@ -56,7 +52,7 @@ private void loadConfigWithoutNamespace() { .resolve()); } - /** Minimal valid INPUT topic — no namespace attached. */ + /** Minimal valid INPUT topic: no namespace attached. */ private Topic inputTopicWithoutNamespace(String name) { return Topic.builder().topicName(name).topicType(Topic.Type.INPUT).build(); } @@ -173,7 +169,7 @@ void blankNamespace_staysUnchanged() { } @Nested - @DisplayName("validateTopic — general guard-rail assertions") + @DisplayName("validateTopic: general guard-rail assertions") class GeneralValidation { @BeforeEach diff --git a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java index 0341959..27b3717 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java @@ -15,9 +15,6 @@ */ package io.github.ktestify.tests.extentions; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.TimeUnit; import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.admin.NewTopic; @@ -28,6 +25,10 @@ import org.testcontainers.kafka.KafkaContainer; import org.testcontainers.utility.DockerImageName; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + /** * JUnit 5 extension that starts a Kafka broker via Testcontainers for the duration of the test class, and exposes * static helpers used by the tests. @@ -49,7 +50,7 @@ public class KafkaTestExtension implements BeforeAllCallback, AfterAllCallback, ExtensionContext.Store.CloseableResource { // ----------------------------------------------------------------- - // Testcontainers image – pin to a specific Confluent Platform version + // Testcontainers image: pin to a specific Confluent Platform version // so that builds are reproducible. Bump when you need a newer Kafka. // ----------------------------------------------------------------- private static final DockerImageName KAFKA_IMAGE = DockerImageName.parse("apache/kafka:4.2.0"); diff --git a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java index 3e7ba79..a3ae801 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java @@ -17,7 +17,6 @@ import io.confluent.kafka.schemaregistry.client.CachedSchemaRegistryClient; import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; -import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; @@ -26,6 +25,8 @@ import org.testcontainers.containers.Network; import org.testcontainers.utility.DockerImageName; +import java.util.Map; + /** * JUnit 5 extension that starts a Confluent Schema Registry container via Testcontainers for the duration of the test * class, and exposes static helpers used by the tests. @@ -51,7 +52,7 @@ public class SchemaRegistryTestExtension implements BeforeAllCallback, AfterAllCallback, ExtensionContext.Store.CloseableResource { // ----------------------------------------------------------------- - // Testcontainers image – pin to a specific Confluent Platform version + // Testcontainers image: pin to a specific Confluent Platform version // so that builds are reproducible. Bump when you need a newer version. // ----------------------------------------------------------------- private static final DockerImageName SCHEMA_REGISTRY_IMAGE = From b55c287da98a60f9464c334c1022fbe0f977aabb Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:28:45 +0200 Subject: [PATCH 15/32] test: add ClasspathTestPlugin for lifecycle call assertions --- .../ktestify/plugin/ClasspathTestPlugin.java | 65 +++++++++++ .../ktestify/plugin/PluginRegistryTest.java | 103 +++++++++++++++--- .../io.github.ktestify.plugin.KtestifyPlugin | 1 + 3 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java create mode 100644 src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin diff --git a/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java b/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java new file mode 100644 index 0000000..62eec80 --- /dev/null +++ b/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.ktestify.plugin; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Test-only plugin registered through {@code src/test/resources/META-INF/services}. It counts lifecycle calls so tests + * can assert that {@link PluginRegistry} initializes and shuts down each plugin exactly once. + */ +public class ClasspathTestPlugin implements KtestifyPlugin { + + /** Plugin id exposed by this test plugin. */ + public static final String ID = "classpath-test"; + + /** Number of {@link #initialize(PluginContext)} calls across all instances. */ + public static final AtomicInteger INIT_CALLS = new AtomicInteger(); + + /** Number of {@link #shutdown()} calls across all instances. */ + public static final AtomicInteger SHUTDOWN_CALLS = new AtomicInteger(); + + /** Resets the lifecycle counters. */ + public static void resetCounters() { + INIT_CALLS.set(0); + SHUTDOWN_CALLS.set(0); + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getVersion() { + return "0.0.0-test"; + } + + @Override + public String getGluePackage() { + return "io.github.ktestify.plugin.testglue"; + } + + @Override + public void initialize(PluginContext context) { + INIT_CALLS.incrementAndGet(); + } + + @Override + public void shutdown() { + SHUTDOWN_CALLS.incrementAndGet(); + } +} diff --git a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java index 13665ab..43d2100 100644 --- a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java +++ b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java @@ -15,17 +15,23 @@ */ package io.github.ktestify.plugin; -import static org.junit.jupiter.api.Assertions.*; - import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.PluginException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.io.TempDir; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.*; /** * Unit tests for {@link PluginRegistry} and {@link KtestifyPlugin}. @@ -50,11 +56,11 @@ void tearDown() { } // ========================================================================= - // PluginRegistry — no plugins + // PluginRegistry: no plugins // ========================================================================= @Nested - @DisplayName("load() — no plugins on classpath / empty dir") + @DisplayName("load(): no plugins on classpath / empty dir") class NoPluginsTests { @Test @@ -64,13 +70,13 @@ void returnsEmptyRegistryWhenNoPlugins() { // (classpath plugins from the main scope are not included in the test CL hierarchy) PluginRegistry registry = PluginRegistry.load(CTX); assertNotNull(registry); - // getGluePackages may be empty or contain classpath plugins — either is valid + // getGluePackages may be empty or contain classpath plugins: either is valid assertNotNull(registry.getPlugins()); assertNotNull(registry.getGluePackages()); } @Test - @DisplayName("empty plugins dir — loads cleanly with no external plugins") + @DisplayName("empty plugins dir: loads cleanly with no external plugins") void emptyPluginsDirLoadsCleanly(@TempDir Path tempDir) { KtestifyConfig cfg = KtestifyConfig.load( ConfigFactory.parseString("ktestify.plugins.dir = \"" + tempDir.toAbsolutePath() + "\"")); @@ -80,7 +86,7 @@ void emptyPluginsDirLoadsCleanly(@TempDir Path tempDir) { } @Test - @DisplayName("non-existent plugins dir — loads cleanly") + @DisplayName("non-existent plugins dir: loads cleanly") void nonExistentPluginsDirLoadsCleanly() { KtestifyConfig cfg = KtestifyConfig.load( ConfigFactory.parseString("ktestify.plugins.dir = \"/does/not/exist/plugins\"")); @@ -89,7 +95,7 @@ void nonExistentPluginsDirLoadsCleanly() { } @Test - @DisplayName("blank plugins dir — skips external loading") + @DisplayName("blank plugins dir: skips external loading") void blankPluginsDirSkipsExternalLoading() { KtestifyConfig cfg = KtestifyConfig.load(ConfigFactory.parseString("ktestify.plugins.dir = \"\"")); @@ -98,7 +104,7 @@ void blankPluginsDirSkipsExternalLoading() { } // ========================================================================= - // PluginRegistry — shutdown + // PluginRegistry: shutdown // ========================================================================= @Nested @@ -111,10 +117,71 @@ void shutdownOnEmptyRegistryDoesNotThrow() { PluginRegistry registry = PluginRegistry.load(CTX); assertDoesNotThrow(registry::shutdown); } + + @Test + @DisplayName("shutdown() is idempotent: plugins are shut down only once") + void shutdownIsIdempotent() { + ClasspathTestPlugin.resetCounters(); + PluginRegistry registry = PluginRegistry.load(CTX); + + registry.shutdown(); + registry.shutdown(); + + assertEquals(1, ClasspathTestPlugin.SHUTDOWN_CALLS.get()); + } + } + + // ========================================================================= + // PluginRegistry: classpath + external directory + // ========================================================================= + + @Nested + @DisplayName("load(): classpath and external directory together") + class DiscoveryTests { + + @Test + @DisplayName("classpath plugin is loaded and initialized exactly once without an external dir") + void classpathPluginLoadedOnce() { + ClasspathTestPlugin.resetCounters(); + PluginRegistry registry = PluginRegistry.load(CTX); + + assertEquals(1, countById(registry, ClasspathTestPlugin.ID)); + assertEquals(1, ClasspathTestPlugin.INIT_CALLS.get()); + registry.shutdown(); + } + + @Test + @DisplayName("classpath plugin is not rediscovered through the external classloader's parent") + void classpathPluginNotDuplicatedByExternalPhase(@TempDir Path tempDir) throws Exception { + // Any JAR in the plugins dir triggers Phase 2, whose URLClassLoader delegates to the classpath. + Path jar = tempDir.resolve("unrelated.jar"); + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) { + out.putNextEntry(new JarEntry("placeholder.txt")); + out.write("x".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + KtestifyConfig cfg = KtestifyConfig.load( + ConfigFactory.parseString("ktestify.plugins.dir = \"" + tempDir.toAbsolutePath() + "\"")); + ClasspathTestPlugin.resetCounters(); + + PluginRegistry registry = PluginRegistry.load(() -> cfg); + + assertEquals(1, countById(registry, ClasspathTestPlugin.ID)); + assertEquals(1, ClasspathTestPlugin.INIT_CALLS.get()); + assertEquals(1, Collections.frequency(registry.getGluePackages(), "io.github.ktestify.plugin.testglue")); + registry.shutdown(); + assertEquals(1, ClasspathTestPlugin.SHUTDOWN_CALLS.get()); + } + + private long countById(PluginRegistry registry, String id) { + return registry.getPlugins().stream() + .filter(p -> id.equals(p.getId())) + .count(); + } } // ========================================================================= - // PluginRegistry — getGluePackages + // PluginRegistry: getGluePackages // ========================================================================= @Nested @@ -122,7 +189,7 @@ void shutdownOnEmptyRegistryDoesNotThrow() { class GluePackagesTests { @Test - @DisplayName("returns list — never null") + @DisplayName("returns list: never null") void getGluePackagesNeverNull() { PluginRegistry registry = PluginRegistry.load(CTX); assertNotNull(registry.getGluePackages()); @@ -131,7 +198,7 @@ void getGluePackagesNeverNull() { @Test @DisplayName("filters out null and blank glue packages") void filtersNullAndBlankGluePackages() { - // StubPlugin with null glue — should not appear in result + // StubPlugin with null glue: should not appear in result StubPlugin nullGlue = new StubPlugin("null-glue", "1.0", null, "A", "a@a.com"); StubPlugin blankGlue = new StubPlugin("blank-glue", "1.0", " ", "A", "a@a.com"); StubPlugin realGlue = new StubPlugin("real-glue", "1.0", "io.github.ktestify.foo", "A", "a@a.com"); @@ -148,11 +215,11 @@ void filtersNullAndBlankGluePackages() { } // ========================================================================= - // KtestifyPlugin — default methods + // KtestifyPlugin: default methods // ========================================================================= @Nested - @DisplayName("KtestifyPlugin — default author methods") + @DisplayName("KtestifyPlugin: default author methods") class DefaultAuthorMethodsTests { @Test @@ -258,7 +325,7 @@ void getConfigReturnsNonNull() { // ========================================================================= /** - * Minimal plugin implementation that uses only default interface methods — validates that {@link KtestifyPlugin} + * Minimal plugin implementation that uses only default interface methods: validates that {@link KtestifyPlugin} * default methods work without any overrides. */ static final class MinimalPlugin implements KtestifyPlugin { @@ -288,7 +355,7 @@ public void shutdown() { } } - /** Full stub — all fields configurable for parameterised test cases. */ + /** Full stub: all fields configurable for parameterised test cases. */ static final class StubPlugin implements KtestifyPlugin { private final String id; private final String version; diff --git a/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin b/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin new file mode 100644 index 0000000..4c24c55 --- /dev/null +++ b/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin @@ -0,0 +1 @@ +io.github.ktestify.plugin.ClasspathTestPlugin From 9ba36a11d88ebc045818c71023432d9248da354d Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:29:20 +0200 Subject: [PATCH 16/32] refactor: enhance plugin loading and shutdown logic --- .../ktestify/plugin/PluginRegistry.java | 207 +++++++++++------- 1 file changed, 129 insertions(+), 78 deletions(-) diff --git a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java index 5c550c0..a9d91dc 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -16,13 +16,15 @@ package io.github.ktestify.plugin; import io.github.ktestify.exceptions.PluginException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Discovers, loads, initializes, and holds all active {@link KtestifyPlugin} instances for the current JVM run. @@ -30,20 +32,25 @@ *

Two loading phases

* *
    - *
  1. Classpath (Phase 1) — {@link ServiceLoader#load(Class)} on the current thread's context classloader. - * Picks up all plugins that are on the classpath (i.e. bundled as Maven dependencies in the fat JAR). The Shade - * {@code ServicesResourceTransformer} ensures all {@code META-INF/services} descriptors survive JAR merging. - *
  2. External directory (Phase 2) — scans the directory configured by {@code ktestify.plugins.dir} (default - * {@code /workspace/plugins}) for {@code *.jar} files. Each JAR is added to a shared {@link URLClassLoader} - * (parent = current context classloader) and its plugins are discovered independently via - * {@link ServiceLoader#load(Class, ClassLoader)}. + *
  3. Classpath (Phase 1): {@link ServiceLoader#load(Class, ClassLoader)} on the current thread's context + * classloader. Picks up all plugins that are on the classpath (i.e. bundled as Maven dependencies in the fat + * JAR). The Shade {@code ServicesResourceTransformer} ensures all {@code META-INF/services} descriptors survive + * JAR merging. + *
  4. External directory (Phase 2): scans the directory configured by {@code ktestify.plugins.dir} (default + * {@code /workspace/plugins}) for {@code *.jar} files. All JARs are added to a shared {@link URLClassLoader} + * (parent = current context classloader). Only providers whose class is defined by that external classloader are + * loaded, so classpath plugins visible through parent delegation are never initialized a second time. *
* - *

Initialization order

+ *

Initialization order and failure handling

+ * + *

Plugins are initialized in discovery order: classpath plugins first, then external plugins. Each plugin is + * initialized with the thread context classloader set to the classloader that defined it. * - * Plugins are initialized in discovery order: classpath plugins first, then external plugins in filesystem order. A - * plugin whose {@link KtestifyPlugin#initialize(PluginContext)} throws will cause an immediate {@link PluginException} - * — the run is aborted. + *

Two plugins declaring the same {@link KtestifyPlugin#getId() id} abort the run with a {@link PluginException}. A + * plugin whose {@link KtestifyPlugin#initialize(PluginContext)} throws, or a broken {@code META-INF/services} entry, + * also aborts the run. In every failure case, plugins that were already initialized are shut down in reverse order and + * the external classloader is closed before the exception propagates. * *

Usage

* @@ -52,7 +59,7 @@ * PluginRegistry registry = PluginRegistry.load(ctx); * * // Inject plugin glue packages into Cucumber CLI - * registry.getGluePackages().forEach(pkg -> args.add("--glue"); args.add(pkg)); + * registry.getGluePackages().forEach(pkg -> { args.add("--glue"); args.add(pkg); }); * * // On JVM shutdown * registry.shutdown(); @@ -72,13 +79,16 @@ public final class PluginRegistry { private final List plugins; /** - * The {@link URLClassLoader} created for external plugin JARs, or {@code null} if no external plugins were loaded. + * The {@link URLClassLoader} created for external plugin JARs, or {@code null} if no external JARs were found. * *

Kept open for the lifetime of the registry because loaded plugin classes reference it. It is closed in * {@link #shutdown()} after all plugins have been shut down. */ private final URLClassLoader externalClassLoader; + /** Guards {@link #shutdown()} so that plugins are shut down at most once. */ + private final AtomicBoolean shutDown = new AtomicBoolean(false); + private PluginRegistry(List plugins, URLClassLoader externalClassLoader) { this.plugins = Collections.unmodifiableList(plugins); this.externalClassLoader = externalClassLoader; @@ -93,27 +103,36 @@ private PluginRegistry(List plugins, URLClassLoader externalClas * * @param ctx the plugin context (config + services) handed to each plugin at init time * @return a fully initialized {@code PluginRegistry} - * @throws PluginException if any plugin fails to initialize + * @throws PluginException if a plugin fails to initialize, a provider cannot be instantiated, or two plugins share + * the same id */ public static PluginRegistry load(PluginContext ctx) { List all = new ArrayList<>(); + Map byId = new HashMap<>(); URLClassLoader externalCL = null; LOG.info("Loading plugins..."); - // Phase 1 — classpath / fat-jar plugins - loadFromClasspath(ctx, all); - - // Phase 2 — external plugin directory - String pluginsDir = resolvePluginsDir(ctx); try { - externalCL = loadFromDirectory(pluginsDir, ctx, all); - } catch (RuntimeException e) { - // If loading from directory fails after the classloader was created, close it to avoid a leak. + // Phase 1: classpath / fat-jar plugins + ClassLoader contextCL = Thread.currentThread().getContextClassLoader(); + discover("classpath", ServiceLoader.load(KtestifyPlugin.class, contextCL), null, ctx, all, byId); + + // Phase 2: external plugin directory + externalCL = createExternalClassLoader(resolvePluginsDir(ctx), contextCL); + if (externalCL != null) { + discover("external", ServiceLoader.load(KtestifyPlugin.class, externalCL), externalCL, ctx, all, byId); + } + } catch (RuntimeException | ServiceConfigurationError e) { + LOG.error("Plugin loading failed, rolling back {} initialized plugin(s).", all.size()); + shutdownAll(all); if (externalCL != null) { closeClassLoaderQuietly(externalCL); } - throw e; + if (e instanceof PluginException pe) { + throw pe; + } + throw new PluginException("Plugin discovery failed: " + e.getMessage(), e); } if (all.isEmpty()) { @@ -144,15 +163,16 @@ public List getPlugins() { /** * Returns the Cucumber glue packages contributed by all loaded plugins. * - *

Each non-blank value returned by {@link KtestifyPlugin#getGluePackage()} is included. The caller should add - * each as a separate {@code --glue } argument to the Cucumber CLI. + *

Each non-blank value returned by {@link KtestifyPlugin#getGluePackage()} is included once. The caller should + * add each as a separate {@code --glue } argument to the Cucumber CLI. * - * @return an ordered list of glue package names — may be empty, never {@code null} + * @return an ordered, duplicate-free list of glue package names; may be empty, never {@code null} */ public List getGluePackages() { return plugins.stream() .map(KtestifyPlugin::getGluePackage) .filter(p -> p != null && !p.isBlank()) + .distinct() .collect(Collectors.toList()); } @@ -160,21 +180,15 @@ public List getGluePackages() { * Shuts down all plugins in reverse initialization order, then closes the external plugin {@link URLClassLoader} if * one was created. * - *

Exceptions thrown by individual plugins are caught, logged as warnings, and swallowed so the remaining plugins - * can still be shut down cleanly. + *

Exceptions thrown by individual plugins are caught and logged as warnings so the remaining plugins can still + * be shut down cleanly. Calling this method more than once has no further effect. */ public void shutdown() { - LOG.info("Shutting down {} plugin(s)…", plugins.size()); - List reversed = new ArrayList<>(plugins); - Collections.reverse(reversed); - for (KtestifyPlugin plugin : reversed) { - try { - plugin.shutdown(); - LOG.info("Plugin '{}' shut down.", plugin.getId()); - } catch (Exception e) { - LOG.warn("Error shutting down plugin '{}' — ignored: {}", plugin.getId(), e.getMessage()); - } + if (!shutDown.compareAndSet(false, true)) { + LOG.debug("Plugin registry already shut down, ignoring repeated call."); + return; } + shutdownAll(plugins); // Close the external URLClassLoader to release file handles on plugin JARs. if (externalClassLoader != null) { @@ -186,14 +200,43 @@ public void shutdown() { // Private helpers // ------------------------------------------------------------------------- - /** Phase 1: discovers plugins already on the current classloader (fat-jar deps). */ - private static void loadFromClasspath(PluginContext ctx, List target) { - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - ServiceLoader loader = ServiceLoader.load(KtestifyPlugin.class, cl); + /** + * Iterates over the providers of {@code loader}, instantiates and initializes each one, and appends it to + * {@code target}. + * + * @param phase a short label used in log messages + * @param loader the service loader to iterate + * @param requiredDefiningLoader when non-null, providers whose class is not defined by this classloader are skipped + * (they were already discovered through the parent classloader in Phase 1) + * @param ctx the plugin context + * @param target the list receiving initialized plugins + * @param byId index used to detect duplicate plugin ids + */ + private static void discover( + String phase, + ServiceLoader loader, + ClassLoader requiredDefiningLoader, + PluginContext ctx, + List target, + Map byId) { int before = target.size(); - for (KtestifyPlugin plugin : loader) { + for (ServiceLoader.Provider provider : loader.stream().toList()) { + Class type = provider.type(); + if (requiredDefiningLoader != null && type.getClassLoader() != requiredDefiningLoader) { + LOG.debug("[{}] Skipping '{}': already visible on the classpath.", phase, type.getName()); + continue; + } + + KtestifyPlugin plugin = provider.get(); + KtestifyPlugin existing = byId.putIfAbsent(plugin.getId(), plugin); + if (existing != null) { + throw new PluginException("Duplicate plugin id '" + plugin.getId() + "': provided by both " + + existing.getClass().getName() + " and " + type.getName() + "."); + } + LOG.info( - "[classpath] Plugin discovered: {} v{} (author: {} <{}>)", + "[{}] Plugin discovered: {} v{} (author: {} <{}>)", + phase, plugin.getId(), plugin.getVersion(), plugin.getAuthorName(), @@ -201,38 +244,37 @@ private static void loadFromClasspath(PluginContext ctx, List ta initPlugin(plugin, ctx); target.add(plugin); } - int loaded = target.size() - before; - LOG.debug("Phase 1 (classpath): {} plugin(s) discovered.", loaded); + LOG.debug("Phase '{}': {} plugin(s) loaded.", phase, target.size() - before); } /** - * Phase 2: scans an external directory for {@code .jar} files, loads them via a {@link URLClassLoader}, and - * discovers plugins inside each JAR. + * Builds a single {@link URLClassLoader} for every {@code *.jar} file in {@code dirPath}. * - * @return the {@link URLClassLoader} created for the external JARs, or {@code null} if no external plugins were - * loaded (directory not configured, missing, or empty) + * @param dirPath the configured plugins directory, may be {@code null} or blank + * @param parent the parent classloader + * @return the classloader, or {@code null} when the directory is not configured, missing, or contains no JARs */ - private static URLClassLoader loadFromDirectory(String dirPath, PluginContext ctx, List target) { + private static URLClassLoader createExternalClassLoader(String dirPath, ClassLoader parent) { if (dirPath == null || dirPath.isBlank()) { LOG.debug("Phase 2 (external): plugins dir not configured."); return null; } File dir = new File(dirPath); - if (!dir.exists() || !dir.isDirectory()) { + if (!dir.isDirectory()) { LOG.debug("Phase 2 (external): directory '{}' does not exist.", dirPath); return null; } File[] jars = dir.listFiles(f -> f.isFile() && f.getName().endsWith(".jar")); if (jars == null || jars.length == 0) { - LOG.debug("Phase 2 (external): no *.jar files found in '{}'", dirPath); + LOG.debug("Phase 2 (external): no *.jar files found in '{}'.", dirPath); return null; } + Arrays.sort(jars); - LOG.info("[external] Scanning '{}' — {} JAR(s) found.", dirPath, jars.length); + LOG.info("[external] Scanning '{}': {} JAR(s) found.", dirPath, jars.length); - // Build a single URLClassLoader for all external jars (parent = current context CL) URL[] urls = Arrays.stream(jars) .map(f -> { try { @@ -243,26 +285,22 @@ private static URLClassLoader loadFromDirectory(String dirPath, PluginContext ct }) .toArray(URL[]::new); - URLClassLoader pluginCL = - new URLClassLoader(urls, Thread.currentThread().getContextClassLoader()); + return new URLClassLoader("ktestify-external-plugins", urls, parent); + } - ServiceLoader loader = ServiceLoader.load(KtestifyPlugin.class, pluginCL); - int before = target.size(); - for (KtestifyPlugin plugin : loader) { - LOG.info( - "[external] Plugin discovered: {} v{} (author: {} <{}>, from '{}')", - plugin.getId(), - plugin.getVersion(), - plugin.getAuthorName(), - plugin.getAuthorEmail(), - dirPath); - initPlugin(plugin, ctx); - target.add(plugin); + /** Shuts down {@code list} in reverse order, logging and swallowing individual failures. */ + private static void shutdownAll(List list) { + LOG.info("Shutting down {} plugin(s)...", list.size()); + List reversed = new ArrayList<>(list); + Collections.reverse(reversed); + for (KtestifyPlugin plugin : reversed) { + try { + withContextClassLoader(plugin, plugin::shutdown); + LOG.info("Plugin '{}' shut down.", plugin.getId()); + } catch (Exception e) { + LOG.warn("Error shutting down plugin '{}', ignored.", plugin.getId(), e); + } } - int loaded = target.size() - before; - LOG.debug("Phase 2 (external): {} plugin(s) discovered from '{}'.", loaded, dirPath); - - return pluginCL; } /** Closes a {@link URLClassLoader} silently, logging any failure as a warning. */ @@ -271,14 +309,14 @@ private static void closeClassLoaderQuietly(URLClassLoader cl) { cl.close(); LOG.debug("External plugin URLClassLoader closed."); } catch (Exception e) { - LOG.warn("Failed to close external plugin URLClassLoader — ignored: {}", e.getMessage()); + LOG.warn("Failed to close external plugin URLClassLoader, ignored.", e); } } /** Calls {@link KtestifyPlugin#initialize(PluginContext)}, wrapping any exception in a {@link PluginException}. */ private static void initPlugin(KtestifyPlugin plugin, PluginContext ctx) { try { - plugin.initialize(ctx); + withContextClassLoader(plugin, () -> plugin.initialize(ctx)); LOG.info("Plugin '{}' initialized.", plugin.getId()); } catch (PluginException e) { throw e; // already wrapped @@ -287,12 +325,25 @@ private static void initPlugin(KtestifyPlugin plugin, PluginContext ctx) { } } + /** Runs {@code action} with the thread context classloader set to the classloader that defined {@code plugin}. */ + private static void withContextClassLoader(KtestifyPlugin plugin, Runnable action) { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + ClassLoader pluginLoader = plugin.getClass().getClassLoader(); + thread.setContextClassLoader(pluginLoader != null ? pluginLoader : previous); + try { + action.run(); + } finally { + thread.setContextClassLoader(previous); + } + } + /** Reads the configured plugins directory from HOCON, returning {@code null} if the path is absent. */ private static String resolvePluginsDir(PluginContext ctx) { try { return ctx.getConfig().getRaw().getString(PLUGINS_DIR_PATH); } catch (Exception e) { - LOG.debug("Could not read '{}' from config — external plugins disabled.", PLUGINS_DIR_PATH); + LOG.debug("Could not read '{}' from config, external plugins disabled.", PLUGINS_DIR_PATH); return null; } } From aa8b70fd3d68e60e79c758613b2b1ae08d75ee77 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:29:43 +0200 Subject: [PATCH 17/32] fix: improve handling of empty records in AttributeRecordMatcher --- .../match/impl/AttributeRecordMatcher.java | 12 +++++----- .../impl/AttributeRecordMatcherTest.java | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java index 5877944..a3c64ba 100644 --- a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java @@ -20,16 +20,18 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import java.util.*; import lombok.extern.slf4j.Slf4j; +import java.util.*; + /** * Generic matcher that asserts one or more {@link ConsumedRecord#getAttributes()} entries against the expected values * supplied via {@link MatchContext#getExpectedAttributes()}. * *

Transport-agnostic by design, this matcher is reused by any transport that populates {@code attributes} (HTTP * status code today, gRPC status / MQ reason code / script exit code in the future). It operates on - * {@code List>} for any {@code V} since it never inspects {@link ConsumedRecord#getValue()}. + * {@code List>} for any {@code V} since it never inspects {@link ConsumedRecord#getValue()}, so a + * record with a {@code null} value is matched on its attributes like any other. * *

Matching rule: every key in {@code expectedAttributes} must be present in the actual record's {@code attributes} * with an exactly-equal String value (case-sensitive). Only the first record in the list is used (single-record @@ -43,19 +45,17 @@ public class AttributeRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - Map expected = context.getExpectedAttributes(); if (expected == null || expected.isEmpty()) { log.debug("No expected attributes configured, nothing to assert."); return MatchResult.pass(); } - if (records == null || records.isEmpty()) { - throw new ComparisonException("AttributeRecordMatcher requires at least one record to compare."); + return MatchResult.noRecords(); } - Map actual = records.get(0).getAttributes(); + Map actual = records.getFirst().getAttributes(); Map safeActual = actual != null ? actual : Collections.emptyMap(); List diffs = new ArrayList<>(); diff --git a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java index 0f5c6f0..ae02892 100644 --- a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java @@ -15,20 +15,21 @@ */ package io.github.ktestify.match.impl; -import static io.github.ktestify.match.impl.MatcherTestSupport.ctxWithAttributes; -import static io.github.ktestify.match.impl.MatcherTestSupport.rawRecordWithAttributes; -import static org.junit.jupiter.api.Assertions.*; - import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; -import java.util.Collections; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static io.github.ktestify.match.impl.MatcherTestSupport.ctxWithAttributes; +import static io.github.ktestify.match.impl.MatcherTestSupport.rawRecordWithAttributes; +import static org.junit.jupiter.api.Assertions.*; + @DisplayName("AttributeRecordMatcher") class AttributeRecordMatcherTest { @@ -147,10 +148,12 @@ void multipleMismatchesReported() { class Misconfiguration { @Test - @DisplayName("throws ComparisonException when there is no record to inspect") - void throwsWithoutRecords() { + @DisplayName("fails with noRecords when there is no record to inspect, like every other matcher") + void failsWithoutRecords() throws ComparisonException { MatchContext context = ctxWithAttributes(Map.of("statusCode", "200")); - assertThrows(ComparisonException.class, () -> matcher.match(List.of(), context)); + MatchResult result = matcher.match(List.of(), context); + assertFalse(result.isPassed()); + assertEquals(MatchResult.noRecords().getDiff(), result.getDiff()); } } } From 3b45d3007511c99af4cfeca00ddc38e79b3a1b06 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:30:14 +0200 Subject: [PATCH 18/32] feat: add AvroJson and AvroLogicalTypesSerializationTest for JSON conversion of Avro logical types --- .../github/ktestify/match/impl/AvroJson.java | 47 +++++++++ .../ktestify/utils/serdes/AvroUtils.java | 41 +++++--- .../AvroLogicalTypesSerializationTest.java | 95 +++++++++++++++++++ .../ktestify/utils/serdes/AvroUtilsTest.java | 47 ++++----- 4 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 src/main/java/io/github/ktestify/match/impl/AvroJson.java create mode 100644 src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java diff --git a/src/main/java/io/github/ktestify/match/impl/AvroJson.java b/src/main/java/io/github/ktestify/match/impl/AvroJson.java new file mode 100644 index 0000000..70f784c --- /dev/null +++ b/src/main/java/io/github/ktestify/match/impl/AvroJson.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.ktestify.match.impl; + +import io.github.ktestify.utils.serdes.AvroDeserializer; +import io.github.ktestify.utils.serdes.AvroUtils; +import org.apache.avro.generic.GenericRecord; + +/** + * Converts an Avro {@link GenericRecord} into the pretty-printed JSON form used by every Avro matcher. + * + *

Logical types are decoded by {@link AvroDeserializer} first, so dates, timestamps and decimals are compared in + * their readable form rather than as raw epoch numbers or bytes. + * + * @since 1.1.4 + */ +final class AvroJson { + + private AvroJson() {} + + /** + * Returns the JSON representation of {@code value}. + * + * @param value the Avro record; must not be {@code null} (callers handle tombstones first) + * @return a pretty-printed JSON string + */ + static String of(GenericRecord value) { + if (value.getSchema() != null) { + return AvroUtils.getPrettyAvroValue( + AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); + } + return AvroUtils.getPrettyAvroValue(value.toString()); + } +} diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index d89de15..3efce05 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -15,12 +15,18 @@ */ package io.github.ktestify.utils.serdes; -import static io.github.ktestify.constants.LogMessagesConstants.*; - import com.google.gson.*; import com.google.gson.reflect.TypeToken; import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.exceptions.ProducerException; +import lombok.NonNull; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.MathContext; @@ -34,13 +40,8 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.*; -import lombok.NonNull; -import org.apache.avro.LogicalTypes; -import org.apache.avro.Schema; -import org.apache.avro.generic.GenericData; -import org.apache.avro.generic.GenericRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import static io.github.ktestify.constants.LogMessagesConstants.*; /** * Utility class for Apache Avro operations, providing comprehensive functionality for: @@ -125,7 +126,14 @@ public static String convertMapToJsonString(Map map) { return "{}"; } - var gson = new GsonBuilder().setPrettyPrinting().create(); + // AvroDeserializer emits java.time values for logical types. Gson has no adapters for them and cannot + // reflect into JDK classes, so they are written as their ISO-8601 string form. + var gson = new GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter( + java.time.temporal.TemporalAccessor.class, (JsonSerializer) + (src, typeOfSrc, context) -> new JsonPrimitive(src.toString())) + .create(); return gson.toJson(map); } @@ -286,8 +294,7 @@ public static boolean deepEquals( * *

    *
  • A simple field name (e.g. {@code "timestamp"}), which excludes any field with that exact name at any - * nesting depth (top-level object, nested object, or inside array elements) — this is the historical - * behavior. + * nesting depth (top-level object, nested object, or inside array elements): this is the historical behavior. *
  • A dot-notation path (e.g. {@code "RootField.NestedField"}), which excludes only the field found at that * exact nested location, leaving sibling fields (and the parent object itself) subject to normal comparison. * Paths can be nested arbitrarily deep (e.g. {@code "a.b.c"}), and also apply within array elements (e.g. @@ -635,7 +642,7 @@ private static boolean isKeyExcluded(String key, String qualifiedKey, ListWhen both values are plain {@link String} instances, no date detection or conversion is attempted — they are + *

    When both values are plain {@link String} instances, no date detection or conversion is attempted: they are * compared literally, so genuine Avro {@code string} fields whose content happens to look like a date are never * misinterpreted. * @@ -730,7 +737,7 @@ private static boolean compareValues( * *

    Since the actual side has already been resolved to an {@link Instant} by {@link AvroDeserializer} (i.e. this * is genuinely a timestamp-millis/timestamp-micros logical-type field), we know for certain the expected string is - * meant to represent a timestamp, so no content-sniffing/guessing is required — only parsing. + * meant to represent a timestamp, so no content-sniffing/guessing is required: only parsing. * * @param expectedValue the expected date string * @return the parsed {@link Instant}, or null if it could not be parsed with any supported format @@ -1153,6 +1160,12 @@ public static Object convertJsonToAvro(@NonNull JsonElement jsonElement, @NonNul private static Object handleUnionType(JsonElement jsonElement, Schema fieldSchema) { LOGGER.debug("Found a UNION Type in the field schema"); + boolean nullable = fieldSchema.getTypes().stream().anyMatch(s -> s.getType() == Schema.Type.NULL); + if (nullable && (jsonElement == null || jsonElement.isJsonNull())) { + LOGGER.debug("JSON value is null and the union accepts NULL"); + return null; + } + for (var schema : fieldSchema.getTypes()) { if (schema.getType() != Schema.Type.NULL) { LOGGER.debug("Found a non-null union type, converting it into Avro"); diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java new file mode 100644 index 0000000..ff93101 --- /dev/null +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.ktestify.utils.serdes; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** Regression tests for Avro logical types going through {@link AvroDeserializer} and {@link AvroUtils}. */ +@DisplayName("Avro logical types to JSON") +class AvroLogicalTypesSerializationTest { + + private static final Schema SCHEMA = new Schema.Parser().parse(""" + { + "type": "record", "name": "Logical", "fields": [ + {"name": "day", "type": {"type": "int", "logicalType": "date"}}, + {"name": "at", "type": {"type": "long", "logicalType": "timestamp-micros"}}, + {"name": "local", "type": {"type": "long", "logicalType": "local-timestamp-millis"}}, + {"name": "time", "type": {"type": "int", "logicalType": "time-millis"}}, + {"name": "payload", "type": "bytes"} + ] + } + """); + + @Test + @DisplayName("java.time values are serialized as ISO-8601 strings") + void javaTimeValuesSerializeAsIsoStrings() { + Map map = new LinkedHashMap<>(); + map.put("day", LocalDate.of(2026, 10, 2)); + map.put("at", Instant.parse("2026-10-02T10:15:30Z")); + map.put("local", LocalDateTime.of(2026, 10, 2, 10, 15, 30)); + map.put("time", LocalTime.of(10, 15, 30)); + + String json = AvroUtils.convertMapToJsonString(map); + Map roundTrip = AvroUtils.convertJsonToMap(json); + + assertEquals("2026-10-02", roundTrip.get("day")); + assertEquals("2026-10-02T10:15:30Z", roundTrip.get("at")); + assertEquals("2026-10-02T10:15:30", roundTrip.get("local")); + assertEquals("10:15:30", roundTrip.get("time")); + } + + @Test + @DisplayName("a GenericRecord with logical types converts to JSON without reflection errors") + void genericRecordWithLogicalTypesConverts() { + GenericRecord record = new GenericData.Record(SCHEMA); + record.put("day", 20_000); + record.put("at", 1_000_000L); + record.put("local", 1_000L); + record.put("time", 1_000); + record.put("payload", ByteBuffer.wrap(new byte[] {1, 2, 3})); + + String json = + assertDoesNotThrow(() -> AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(record))); + + assertTrue(json.contains("\"day\": \"2024-10-04\""), json); + } + + @Test + @DisplayName("bytes honour the ByteBuffer position and limit") + void bytesHonourBufferWindow() { + ByteBuffer buffer = ByteBuffer.wrap(new byte[] {9, 1, 2, 9}, 1, 2).slice(); + Schema bytesSchema = Schema.create(Schema.Type.BYTES); + + byte[] result = (byte[]) AvroDeserializer.objectDeserializer(buffer, bytesSchema); + + assertArrayEquals(new byte[] {1, 2}, result); + assertEquals(0, buffer.position(), "source buffer must not be consumed"); + } +} diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index e7bcd0c..19c672c 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -15,19 +15,8 @@ */ package io.github.ktestify.utils.serdes; -import static org.junit.jupiter.api.Assertions.*; - import com.google.gson.*; import io.github.ktestify.exceptions.ComparisonException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.nio.ByteBuffer; -import java.text.ParseException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.*; import lombok.extern.slf4j.Slf4j; import org.apache.avro.LogicalTypes; import org.apache.avro.Schema; @@ -39,6 +28,18 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.nio.ByteBuffer; +import java.text.ParseException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + @Slf4j class AvroUtilsTest { @@ -765,7 +766,7 @@ void testConvertDatesToTimestamps_NestedObject() { Map jsonMap = AvroUtils.convertJsonToMap(json); Map result = AvroUtils.convertDatesToTimestamps(jsonMap); - // Recursion into nested maps now works, but no conversion is performed — the nested date-like string + // Recursion into nested maps now works, but no conversion is performed: the nested date-like string // must remain untouched. assertEquals("2025-01-02T00:00:00Z", ((Map) result.get("nested")).get("date")); } @@ -791,7 +792,7 @@ void testConvertDatesToTimestamps_NestedList() { List list = (List) result.get("list"); - // Recursion into nested list items works, but no conversion is performed — dates remain untouched. + // Recursion into nested list items works, but no conversion is performed: dates remain untouched. assertEquals("2025-01-02T00:00:00Z", ((Map) list.get(0)).get("date")); assertEquals("2025-01-03T00:00:00Z", ((Map) list.get(1)).get("date")); } @@ -1176,7 +1177,7 @@ void testConvertDatesToTimestampsInNestedStructures() { Map result = AvroUtils.convertDatesToTimestamps(inputMap); - // No conversion is performed anymore (content-sniffing removed) — dates remain untouched at all levels, + // No conversion is performed anymore (content-sniffing removed): dates remain untouched at all levels, // but the recursion still produces a correctly structured (deep-copied) map. assertEquals("2022-01-03T00:00:00Z", result.get("date"), "Top level date should remain unconverted"); assertEquals( @@ -2499,14 +2500,14 @@ class EffectiveSizeCheckWithExcludedKeysTests { @Test @DisplayName("Should return true when expected has an extra key that is excluded (core bug regression)") void shouldReturnTrue_WhenExpectedHasExtraExcludedKey() { - // expected has key3 which is excluded — old code failed here (size 3 != 2) + // expected has key3 which is excluded: old code failed here (size 3 != 2) String expected = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"}"; String actual = "{\"key1\":\"value1\",\"key2\":\"value2\"}"; List excludedKeys = List.of("key3"); assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Expected map has an extra key that is excluded — effective sizes are equal, should match"); + "Expected map has an extra key that is excluded: effective sizes are equal, should match"); } @Test @@ -2519,7 +2520,7 @@ void shouldReturnTrue_WhenActualHasExtraExcludedKey() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Actual map has an extra key that is excluded — effective sizes are equal, should match"); + "Actual map has an extra key that is excluded: effective sizes are equal, should match"); } @Test @@ -2533,20 +2534,20 @@ void shouldReturnTrue_WhenEachMapHasDistinctExtraExcludedKey() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Each map carries a different extra excluded key — effective sizes are equal, should match"); + "Each map carries a different extra excluded key: effective sizes are equal, should match"); } @Test @DisplayName("Should return false when effective sizes still differ after accounting for excluded keys") void shouldReturnFalse_WhenEffectiveSizesStillDifferAfterExclusion() { - // expected has key2 + key3 (key3 excluded), actual has only key1 — effective 2 vs 1 + // expected has key2 + key3 (key3 excluded), actual has only key1: effective 2 vs 1 String expected = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"}"; String actual = "{\"key1\":\"value1\"}"; List excludedKeys = List.of("key3"); assertFalse( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Even after excluding key3, expected still has more non-excluded keys than actual — should not match"); + "Even after excluding key3, expected still has more non-excluded keys than actual: should not match"); } @Test @@ -2559,7 +2560,7 @@ void shouldNotAffectEffectiveSize_WhenExcludedKeyAbsentFromBothMaps() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "An excluded key absent from both maps must not distort effective sizes — equal maps should still match"); + "An excluded key absent from both maps must not distort effective sizes: equal maps should still match"); } @Test @@ -2572,7 +2573,7 @@ void shouldReturnTrue_WhenMultipleExtraExcludedKeysOnlyInExpected() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Multiple extra excluded keys only in expected — effective sizes both 2, should match"); + "Multiple extra excluded keys only in expected: effective sizes both 2, should match"); } } @@ -2584,7 +2585,7 @@ class TypeDrivenDateComparisonTests { @DisplayName("Should compare literally when both sides are plain strings, even if date-like") void shouldCompareLiterally_WhenBothSidesAreDateLikeStrings() { // A genuine Avro `string` field whose content happens to look like a date/timestamp must never be - // converted or reinterpreted — it must be compared as a literal string on both sides. + // converted or reinterpreted: it must be compared as a literal string on both sides. Map expected = new HashMap<>(); expected.put("MyDate", "2026-02-10T16:19:14.123Z"); From 37681840edcc3c26f4b85622886a920e0d565c23 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:30:27 +0200 Subject: [PATCH 19/32] feat: enhance DynamicVariableFactory for thread-safety and case-insensitivity --- .../io/inputs/DynamicVariableFactory.java | 73 ++++++++++++++++--- .../io/inputs/DynamicVariableFactoryTest.java | 38 ++++++++-- 2 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java index 8735dfd..f283df4 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java @@ -19,43 +19,92 @@ import io.github.ktestify.io.inputs.types.EnvironmentVariable; import io.github.ktestify.io.inputs.types.RandomVariable; import io.github.ktestify.io.inputs.types.TimestampVariable; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe registry of {@link DynamicVariable} implementations, keyed by variable name. + * + *

    Names are case-insensitive: {@code {{env:HOME}}}, {@code {{ENV:HOME}}} and {@code {{Env:HOME}}} all resolve to the + * same {@link EnvironmentVariable}. The built-in variables ({@code date}, {@code timestamp}, {@code random}, + * {@code env}) are registered at class initialization and can be restored with {@link #resetToDefaults()}. + * + * @since 0.1.0 + */ @UtilityClass public class DynamicVariableFactory { - private static final Map variables = new HashMap<>(); + private static final Map variables = new ConcurrentHashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableFactory.class); static { - registerVariable(new DateVariable()); - registerVariable(new TimestampVariable()); - registerVariable(new RandomVariable()); - registerVariable(new EnvironmentVariable()); + resetToDefaults(); } + /** + * Registers a variable under its {@link DynamicVariable#getName() name}, replacing any variable already registered + * under the same name (case-insensitive). + * + * @param variable the variable to register + */ public static void registerVariable(DynamicVariable variable) { LOGGER.debug("Registering variable {}.", variable.getName()); - variables.put(variable.getName(), variable); + variables.put(normalize(variable.getName()), variable); } + /** + * Returns the variable registered under {@code name}. + * + * @param name the variable name, case-insensitive + * @return the variable, or {@code null} if none is registered + */ public static DynamicVariable getVariable(String name) { - return variables.get(name); + return name == null ? null : variables.get(normalize(name)); } + /** + * Returns whether a variable is registered under {@code name}. + * + * @param name the variable name, case-insensitive + * @return {@code true} if a variable is registered + */ public static boolean isRegistered(String name) { - return variables.containsKey(name); + return name != null && variables.containsKey(normalize(name)); } + /** + * Returns an immutable snapshot of the registered (lower-case) variable names. + * + * @return the registered names + */ public static Set getRegisteredVariableNames() { - return variables.keySet(); + return Set.copyOf(variables.keySet()); } + /** Removes every registered variable, including the built-ins. Use {@link #resetToDefaults()} to restore them. */ public static void clearRegisteredVariables() { variables.clear(); } + + /** + * Clears the registry and registers the built-in variables again. + * + * @since 1.1.4 + */ + public static void resetToDefaults() { + variables.clear(); + registerVariable(new DateVariable()); + registerVariable(new TimestampVariable()); + registerVariable(new RandomVariable()); + registerVariable(new EnvironmentVariable()); + } + + private static String normalize(String name) { + return name.toLowerCase(Locale.ROOT); + } } diff --git a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java index 5b70bb1..ad9d5ac 100644 --- a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java @@ -15,17 +15,18 @@ */ package io.github.ktestify.io.inputs; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + public class DynamicVariableFactoryTest { @Mock @@ -48,8 +49,31 @@ public void setUp() { @AfterEach public void tearDown() { - // Clean up after each test to avoid interference - DynamicVariableFactory.clearRegisteredVariables(); + // Restore the built-ins so other test classes are not affected + DynamicVariableFactory.resetToDefaults(); + } + + @Test + public void testLookupIsCaseInsensitive() { + DynamicVariableFactory.resetToDefaults(); + + assertTrue(DynamicVariableFactory.isRegistered("env")); + assertTrue(DynamicVariableFactory.isRegistered("ENV")); + assertTrue(DynamicVariableFactory.isRegistered("Env")); + assertSame(DynamicVariableFactory.getVariable("env"), DynamicVariableFactory.getVariable("ENV")); + } + + @Test + public void testGetVariableWithNullNameReturnsNull() { + assertNull(DynamicVariableFactory.getVariable(null)); + assertFalse(DynamicVariableFactory.isRegistered(null)); + } + + @Test + public void testResetToDefaultsRestoresBuiltIns() { + DynamicVariableFactory.resetToDefaults(); + + assertEquals(Set.of("date", "timestamp", "random", "env"), DynamicVariableFactory.getRegisteredVariableNames()); } @Test From a0655e9e10bbd293d2812b8b84aa34683ed32215 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:30:46 +0200 Subject: [PATCH 20/32] fix: improve getLine method to handle null content and out-of-bounds indices --- .../io/github/ktestify/utils/FieldMatcherUtils.java | 13 ++++++++++++- .../ktestify/utils/FieldMatcherUtilsTest.java | 13 ++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java b/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java index 720b06f..d5c1b3f 100644 --- a/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java +++ b/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java @@ -28,12 +28,23 @@ public final class FieldMatcherUtils { /** * Returns the line at the given 0-based index from a multi-line string. * + *

    Both {@code \n} and {@code \r\n} line endings are supported; the line terminator is never part of the result. + * * @param content the full record content * @param line 0-based line index * @return the content of the requested line + * @throws IllegalArgumentException if the content is null or the line index is out of range */ public static String getLine(String content, int line) { - return content.split("\n")[line]; + if (content == null) { + throw new IllegalArgumentException("Content cannot be null."); + } + String[] lines = content.split("\r?\n", -1); + if (line < 0 || line >= lines.length) { + throw new IllegalArgumentException( + "Invalid line index " + line + " (content has " + lines.length + " line(s))."); + } + return lines[line]; } /** diff --git a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java index dca8656..5f254ca 100644 --- a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java @@ -15,11 +15,11 @@ */ package io.github.ktestify.utils; -import static org.junit.jupiter.api.Assertions.assertThrows; - import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; + public class FieldMatcherUtilsTest { @Test void testGetLine() { @@ -31,7 +31,14 @@ void testGetLine() { @Test void testGetLineOutOfBounds() { String content = "First line\nSecond line"; - assertThrows(ArrayIndexOutOfBoundsException.class, () -> FieldMatcherUtils.getLine(content, 2)); + assertThrows(IllegalArgumentException.class, () -> FieldMatcherUtils.getLine(content, 2)); + assertThrows(IllegalArgumentException.class, () -> FieldMatcherUtils.getLine(content, -1)); + } + + @Test + void testGetLineStripsCarriageReturn() { + String content = "First line\r\nSecond line\r\n"; + Assertions.assertEquals("Second line", FieldMatcherUtils.getLine(content, 1)); } @Test From 1f4ae4411258e5e77c0cb7f5647af443b16b3201 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:31:15 +0200 Subject: [PATCH 21/32] refactor: enhance timeout handling and improve documentation in KafkaRecordFetcher --- .../ktestify/io/kafka/KafkaRecordFetcher.java | 145 +++++++++++------- 1 file changed, 92 insertions(+), 53 deletions(-) diff --git a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java index 045209e..f782cc9 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -15,22 +15,12 @@ */ package io.github.ktestify.io.kafka; -import static io.github.ktestify.constants.LogMessagesConstants.*; - import io.github.ktestify.config.FrameworkConfig; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.FetchException; import io.github.ktestify.io.core.RecordFetcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.models.MatchedRecord; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.Consumer; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -39,6 +29,14 @@ import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.errors.WakeupException; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static io.github.ktestify.constants.LogMessagesConstants.*; + /** * Kafka implementation of {@link RecordFetcher}. * @@ -52,7 +50,7 @@ *

* *

This class knows nothing about matching or assertions. It produces {@link ConsumedRecord} objects - * and hands them to whoever called {@link #fetch()} — typically an {@code AbstractKafkaConsumer} subclass. + * and hands them to whoever called {@link #fetch()}: typically an {@code AbstractKafkaConsumer} subclass. * * @param the Kafka record key type * @param the Kafka record value type @@ -105,23 +103,30 @@ public KafkaRecordFetcher(ConsumerContext context) { /** * {@inheritDoc} * - *

Blocks until at least one record that passes the key-filter is found, or {@code readTimeoutMs} elapses. + *

Blocks until at least one record that passes the key-filter is found, or {@code readTimeoutMs} elapses. The + * read timeout is a single budget that covers partition assignment, offset lookup, and polling. * - * @throws FetchException if the timeout expires without a matching record, or if the Kafka consumer is woken up - * externally + * @throws FetchException if the timeout expires without a matching record, if partition assignment or offset lookup + * does not complete in time, or if the Kafka consumer is woken up externally */ @Override public List> fetch() throws FetchException { + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(readTimeoutMs); try { - subscribeAndAwaitAssignment(); + subscribeAndAwaitAssignment(deadlineNanos); long delta = calculateDeltaTime(); - seekToOffset(delta); - return pollUntilRecordFound(); + seekToOffset(delta, deadlineNanos); + return pollUntilRecordFound(deadlineNanos); } catch (WakeupException e) { throw new FetchException( "Kafka consumer was woken up for shutdown on topic '" + context.getTopic().getNamespacedTopic() + "'", e); + } catch (org.apache.kafka.common.errors.TimeoutException e) { + throw new FetchException( + "Kafka operation timed out on topic '" + context.getTopic().getNamespacedTopic() + "': " + + e.getMessage(), + e); } } @@ -148,8 +153,9 @@ public void close() { /** * Clears the shared deduplication registry. * - *

Call this at the start of each independent test scenario (e.g. in a {@code @Before} / {@code Before} hook) to - * ensure records from a previous scenario are not filtered out. + *

The registry is intentionally not cleared automatically between scenarios, so that a record + * already matched by an earlier scenario cannot be matched again later in the same run. Call this method only for + * an explicit reset, for example from the {@code clear known messages} step. */ public static void clearMatchedRecords() { MATCHED_RECORDS.clear(); @@ -157,17 +163,27 @@ public static void clearMatchedRecords() { } // ========================================================================= - // Private — Kafka mechanics (previously inside AbstractKafkaConsumer) + // Private: Kafka mechanics // ========================================================================= - /** Subscribes to the configured topic and blocks until Kafka assigns partitions. */ - private void subscribeAndAwaitAssignment() { + /** + * Subscribes to the configured topic and blocks until Kafka assigns partitions or the deadline passes. + * + * @param deadlineNanos absolute {@link System#nanoTime()} deadline + * @throws FetchException if no partition is assigned before the deadline + */ + private void subscribeAndAwaitAssignment(long deadlineNanos) throws FetchException { String namespacedTopic = context.getTopic().getNamespacedTopic(); kafkaConsumer.subscribe(Collections.singletonList(namespacedTopic)); log.info(MESSAGE_CONSUMER_SUBSCRIBED_TO_TOPIC, namespacedTopic); long pollIntervalMs = frameworkConfig.getPollIntervalMillis(); while (kafkaConsumer.assignment().isEmpty()) { + if (System.nanoTime() >= deadlineNanos) { + throw new FetchException("Timed out after " + readTimeoutMs + + "ms waiting for partition assignment on topic '" + namespacedTopic + + "'. Check that the broker is reachable and the topic exists."); + } kafkaConsumer.poll(Duration.ofMillis(pollIntervalMs)); } log.debug("Partition assignment received for topic '{}'.", namespacedTopic); @@ -177,7 +193,7 @@ private void subscribeAndAwaitAssignment() { * Calculates the earliest timestamp to read from. * *

The "now" reference used below is either {@link ConsumerContext#getReferenceTimestamp()}, when the caller has - * pinned it — or the live {@code System.currentTimeMillis()} otherwise. Pinning "now" lets a single Cucumber step + * pinned it: or the live {@code System.currentTimeMillis()} otherwise. Pinning "now" lets a single Cucumber step * spawn several internal fetches (e.g. a batch consumer, or multiple {@code Then} steps executed in quick * succession) without the seek offset drifting forward as wall-clock time advances between them. * @@ -200,7 +216,7 @@ private long calculateDeltaTime() { return delta; } - // 2. Properties map value (in seconds — convert to ms) + // 2. Properties map value (in seconds: convert to ms) long defaultDeltaMs = frameworkConfig.getConsumerDeltaTime().toMillis(); String deltaTimeStr = properties.get(CONSUMER_DELTA_TIME); if (deltaTimeStr != null && !deltaTimeStr.isEmpty()) { @@ -222,14 +238,19 @@ private long calculateDeltaTime() { /** * Seeks each assigned partition to the offset corresponding to {@code deltaTimestamp}. Partitions with no messages * after that timestamp are seeked to the end. + * + * @param deltaTimestamp the epoch-millisecond timestamp to seek to + * @param deadlineNanos absolute {@link System#nanoTime()} deadline bounding the offset lookup */ - private void seekToOffset(long deltaTimestamp) { + private void seekToOffset(long deltaTimestamp, long deadlineNanos) { Set assignments = kafkaConsumer.assignment(); log.debug(MESSAGE_CONSUMER_SEEKING_TO_OFFSET, deltaTimestamp); Map timestampMap = assignments.stream().collect(Collectors.toMap(tp -> tp, tp -> deltaTimestamp)); - Map offsets = kafkaConsumer.offsetsForTimes(timestampMap); + long remainingMs = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime())); + Map offsets = + kafkaConsumer.offsetsForTimes(timestampMap, Duration.ofMillis(remainingMs)); log.trace(MESSAGE_CONSUMER_RETRIEVED_OFFSETS, timestampMap); offsets.forEach((topicPartition, offsetAndTimestamp) -> { @@ -244,24 +265,31 @@ private void seekToOffset(long deltaTimestamp) { } /** - * Polls Kafka until a candidate record is found or {@link #readTimeoutMs} expires. + * Polls Kafka until a candidate record is found or the deadline passes. * *

In single-record mode (default) the method returns as soon as at least one candidate passes the - * key-filter and deduplication check. + * key-filter and is successfully claimed in the deduplication registry. * *

In batch mode ({@link ConsumerContext#isBatchConsumer()} == {@code true}) the method keeps polling - * until {@link ConsumerContext#getBatchSize()} distinct candidates have been collected, or the timeout expires. + * until {@link ConsumerContext#getBatchSize()} distinct candidates have been collected, or the deadline passes. If + * the batch is incomplete at the deadline, the records it claimed are released so other consumers can still match + * them. * + * @param deadlineNanos absolute {@link System#nanoTime()} deadline * @return non-empty, unmodifiable list of {@link ConsumedRecord} ready for matching - * @throws FetchException if the timeout expires before the required number of records is found + * @throws FetchException if the batch size is invalid, or the deadline passes before the required number of records + * is found */ - private List> pollUntilRecordFound() throws FetchException { - long startTime = System.currentTimeMillis(); + private List> pollUntilRecordFound(long deadlineNanos) throws FetchException { long pollIntervalMs = frameworkConfig.getPollIntervalMillis(); String namespacedTopic = context.getTopic().getNamespacedTopic(); boolean batchMode = context.isBatchConsumer(); int targetSize = batchMode ? context.getBatchSize() : 1; + if (targetSize < 1) { + throw new FetchException( + "Invalid batch size " + targetSize + " for topic '" + namespacedTopic + "': must be at least 1."); + } List> accumulated = new ArrayList<>(); log.debug( @@ -270,36 +298,34 @@ private List> pollUntilRecordFound() throws FetchException { batchMode ? "BATCH" : "SINGLE", targetSize); - while (System.currentTimeMillis() - startTime < readTimeoutMs) { + while (System.nanoTime() < deadlineNanos) { ConsumerRecords batch = kafkaConsumer.poll(Duration.ofMillis(pollIntervalMs)); if (!batch.isEmpty()) { log.debug(MESSAGE_CONSUMER_RECORDS_IN_TOPIC_NOT_EMPTY, namespacedTopic); for (ConsumerRecord record : batch) { - ConsumedRecord consumed = ConsumedRecord.fromKafkaRecord(record); - - if (isAlreadyMatched(consumed)) { - log.debug(MESSAGE_CONSUMER_RECORD_ALREADY_MATCHED, consumed.toMatchedRecord()); + if (!passesKeyFilter(record)) { continue; } - if (!passesKeyFilter(record)) { + ConsumedRecord consumed = ConsumedRecord.fromKafkaRecord(record); + if (!tryClaim(consumed)) { + log.debug(MESSAGE_CONSUMER_RECORD_ALREADY_MATCHED, consumed.toMatchedRecord()); continue; } log.info(MESSAGE_CONSUMER_GOT_RECORD_WITH_KEY_AND_VALUE, record.key(), record.value()); - registerAsMatched(consumed); accumulated.add(consumed); // In SINGLE mode return immediately on the first passing record. - // In BATCH mode stop as soon as targetSize is reached — do NOT + // In BATCH mode stop as soon as targetSize is reached: do NOT // continue draining the current poll batch beyond the target. if (!batchMode) { return Collections.unmodifiableList(accumulated); } if (accumulated.size() >= targetSize) { log.info( - "Batch complete — collected {} / {} records from topic '{}'.", + "Batch complete: collected {} / {} records from topic '{}'.", accumulated.size(), targetSize, namespacedTopic); @@ -310,10 +336,11 @@ private List> pollUntilRecordFound() throws FetchException { } if (!accumulated.isEmpty()) { - // Partial batch collected — treat as a timeout with context + // Partial batch: give the claimed records back before reporting the timeout. + accumulated.forEach(this::release); throw new FetchException("Timed out after " + readTimeoutMs + "ms waiting for " + targetSize + " record(s) on topic '" + namespacedTopic - + "' — only " + accumulated.size() + " collected."); + + "': only " + accumulated.size() + " collected."); } throw new FetchException( @@ -321,18 +348,30 @@ private List> pollUntilRecordFound() throws FetchException { } // ========================================================================= - // Private — filters and deduplication + // Private: filters and deduplication // ========================================================================= - /** Returns {@code true} if the record has already been claimed by another consumer in this test execution. */ - private boolean isAlreadyMatched(ConsumedRecord record) { - return MATCHED_RECORDS.contains(record.toMatchedRecord()); + /** + * Atomically claims a record in the shared deduplication registry. + * + *

The check and the registration happen in a single {@link Set#add(Object)} call on a concurrent set, so two + * fetchers running in parallel can never both claim the same record. + * + * @param record the candidate record + * @return {@code true} if this fetcher claimed the record, {@code false} if it was already claimed + */ + private boolean tryClaim(ConsumedRecord record) { + MatchedRecord token = record.toMatchedRecord(); + boolean claimed = MATCHED_RECORDS.add(token); + if (claimed) { + log.debug(MESSAGE_CONSUMER_RECORD_NOT_MATCHED_YET, token); + } + return claimed; } - /** Registers a record in the shared deduplication registry. */ - private void registerAsMatched(ConsumedRecord record) { - MATCHED_RECORDS.add(record.toMatchedRecord()); - log.debug(MESSAGE_CONSUMER_RECORD_NOT_MATCHED_YET, record.toMatchedRecord()); + /** Removes a previously claimed record from the shared deduplication registry. */ + private void release(ConsumedRecord record) { + MATCHED_RECORDS.remove(record.toMatchedRecord()); } /** @@ -347,7 +386,7 @@ private boolean passesKeyFilter(ConsumerRecord record) { } if (expectedKey == null || expectedKey.isEmpty()) { - return true; // No filter configured — accept any key + return true; // No filter configured: accept any key } String recordKey = record.key() != null ? record.key().toString() : null; @@ -361,7 +400,7 @@ private boolean passesKeyFilter(ConsumerRecord record) { } // ========================================================================= - // Private — timeout resolution + // Private: timeout resolution // ========================================================================= private long resolveReadTimeout() { From d6145f206e330719623f1fc7380ac5c68e0c65e7 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:31:20 +0200 Subject: [PATCH 22/32] feat: added two new MatchResult noRecords and nullValue --- .../io/github/ktestify/match/MatchResult.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/java/io/github/ktestify/match/MatchResult.java b/src/main/java/io/github/ktestify/match/MatchResult.java index 340f310..0a5c09a 100644 --- a/src/main/java/io/github/ktestify/match/MatchResult.java +++ b/src/main/java/io/github/ktestify/match/MatchResult.java @@ -96,4 +96,32 @@ public static MatchResult fail(String diff, String expected, String actual) { public static MatchResult fail(String message) { return new MatchResult(false, message, "", ""); } + + /** + * Creates a failing result for the case where the transport delivered no record to compare. + * + *

Matchers return this instead of throwing {@link IndexOutOfBoundsException} when they receive an empty or + * {@code null} record list. + * + * @return a failing {@code MatchResult} + * @since 1.1.4 + */ + public static MatchResult noRecords() { + return fail("No record was available to match."); + } + + /** + * Creates a failing result for a record whose value is {@code null}, for example a Kafka tombstone. + * + *

Every matcher that inspects the record value returns this instead of throwing a {@link NullPointerException}. + * Matchers that only inspect the key or transport attributes do not use it, because a tombstone still carries a + * key. + * + * @param expected the expected value, used for reporting; may be {@code null} + * @return a failing {@code MatchResult} + * @since 1.1.4 + */ + public static MatchResult nullValue(String expected) { + return new MatchResult(false, "Record value is null (tombstone).", expected, null); + } } From c52efc6d2d478408664451afd8f40027013842cd Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:32:34 +0200 Subject: [PATCH 23/32] style: improve comments --- pom.xml | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/pom.xml b/pom.xml index f36d2f0..3d58ff3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.github.ktestify ktestify-parent - 1.0.2 + 1.0.3-SNAPSHOT @@ -40,7 +40,7 @@ scm:git:git://github.com/ktestify/ktestify-core.git - scm:git:ssh://github.com:ktestify/ktestify-core.git + scm:git:ssh://git@github.com/ktestify/ktestify-core.git https://github.com/ktestify/ktestify-core/tree/main @@ -105,7 +105,7 @@ slf4j-api - + org.apache.logging.log4j log4j-slf4j2-impl @@ -152,7 +152,7 @@ - + org.junit.jupiter junit-jupiter-api @@ -196,34 +196,12 @@ - + + org.jacoco jacoco-maven-plugin - - - jacoco-check - - check - - verify - - - - BUNDLE - - - LINE - COVEREDRATIO - 0.70 - - - - - - - From 2f57c48b3fa0951b9e4cbbe2854e371251e3285d Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:32:42 +0200 Subject: [PATCH 24/32] test: add TombstoneMatcherTest to verify tombstone handling across matchers --- .../match/impl/TombstoneMatcherTest.java | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java diff --git a/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java new file mode 100644 index 0000000..c9d449b --- /dev/null +++ b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.ktestify.match.impl; + +import io.github.ktestify.exceptions.ComparisonException; +import io.github.ktestify.match.MatchContext; +import io.github.ktestify.match.MatchResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static io.github.ktestify.match.impl.MatcherTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Checks that every matcher handles a tombstone (record with a {@code null} value) the same way: value matchers fail + * cleanly without throwing, key-only and attribute matchers ignore the value. + */ +@DisplayName("Tombstone handling across all matchers") +class TombstoneMatcherTest { + + private static final String KEY = "test-key"; + + private static void assertNullValueFailure(MatchResult result) { + assertFalse(result.isPassed()); + assertTrue(result.getDiff().contains(MatchResult.NULL_VALUE_MESSAGE), result.getDiff()); + } + + @Nested + @DisplayName("String value matchers fail with the tombstone message") + class StringValueMatchers { + + @Test + void fileRecordMatcher() throws ComparisonException { + MatchResult result = + new FileRecordMatcher().match(rawRecord(KEY, null), ctxWithFile("expected-order.json")); + assertNullValueFailure(result); + assertNull(result.getActual()); + } + + @Test + void fileKeyRecordMatcher() throws ComparisonException { + MatchResult result = new FileKeyRecordMatcher() + .match(rawRecord(KEY, null), ctxWithFileAndKey("expected-order.json", KEY)); + assertNullValueFailure(result); + } + + @Test + void fieldsRecordMatcherInline() throws ComparisonException { + MatchResult result = + new FieldsRecordMatcher().match(rawRecord(KEY, null), ctxWithKeyAndValue("0:0:3", "ABC")); + assertNullValueFailure(result); + } + + @Test + void fieldsRecordMatcherFromFile() throws ComparisonException { + MatchContext ctx = MatchContext.builder() + .matchKey("0:0:3") + .matchFilePath(resourcePath("positional-record.txt")) + .build(); + assertNullValueFailure(new FieldsRecordMatcher().match(rawRecord(KEY, null), ctx)); + } + + @Test + void xmlRecordMatcher() throws ComparisonException { + assertNullValueFailure( + new XmlRecordMatcher().match(rawRecord(KEY, null), ctxWithFile("expected-order.xml"))); + } + + @Test + void xPathRecordMatcher() throws ComparisonException { + MatchContext ctx = MatchContext.builder() + .matchFilePath(resourcePath("expected-order.xml")) + .excludedFields(List.of("/order/id")) + .build(); + assertNullValueFailure(new XPathRecordMatcher().match(rawRecord(KEY, null), ctx)); + } + } + + @Nested + @DisplayName("Avro value matchers fail with the tombstone message") + class AvroValueMatchers { + + @Test + void avroFileRecordMatcher() throws ComparisonException { + assertNullValueFailure( + new AvroFileRecordMatcher().match(avroRecord(KEY, null), ctxWithFile("expected-order.json"))); + } + + @Test + void avroFileKeyRecordMatcher() throws ComparisonException { + assertNullValueFailure(new AvroFileKeyRecordMatcher() + .match(avroRecord(KEY, null), ctxWithFileAndKey("expected-order.json", KEY))); + } + + @Test + void avroFieldsRecordMatcherInline() throws ComparisonException { + assertNullValueFailure( + new AvroFieldsRecordMatcher().match(avroRecord(KEY, null), ctxWithKeyAndValue("orderId", "1"))); + } + + @Test + void avroFieldsRecordMatcherFromFile() throws ComparisonException { + assertNullValueFailure(new AvroFieldsRecordMatcher() + .match(avroRecord(KEY, null), ctxWithFileAndKey("expected-order.json", "orderId"))); + } + + @Test + void avroFieldsRecordMatcherMultiField() throws ComparisonException { + assertNullValueFailure(new AvroFieldsRecordMatcher() + .match(avroRecord(KEY, null), ctxWithKeyValues(Map.of("orderId", "1")))); + } + } + + @Nested + @DisplayName("Matchers that never read the value accept a tombstone") + class ValueAgnosticMatchers { + + @Test + void keyRecordMatcher() throws ComparisonException { + assertTrue(new KeyRecordMatcher() + .match(rawRecord(KEY, null), ctxWithKey(KEY)) + .isPassed()); + } + + @Test + void avroKeyRecordMatcher() throws ComparisonException { + assertTrue(new AvroKeyRecordMatcher() + .match(avroRecord(KEY, null), ctxWithKey(KEY)) + .isPassed()); + } + + @Test + void noOpRecordMatcher() { + assertTrue(new NoOpRecordMatcher() + .match(rawRecord(KEY, null), MatchContext.builder().build()) + .isPassed()); + } + } + + @Nested + @DisplayName("Every matcher returns noRecords for an empty or null list") + class EmptyInput { + + @Test + void noOpToleratesNullList() { + assertTrue(new NoOpRecordMatcher() + .match(null, MatchContext.builder().build()) + .isPassed()); + } + + @Test + void attributeMatcherReturnsNoRecords() throws ComparisonException { + MatchResult result = new AttributeRecordMatcher() + .match( + List.of(), + MatchContext.builder() + .expectedAttributes(Map.of("statusCode", "200")) + .build()); + assertFalse(result.isPassed()); + } + } +} From 21b00fb6165cb61d782abd3b11838d989cf05ec7 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 22:35:16 +0200 Subject: [PATCH 25/32] refactor: extract null value message to constant in MatchResult --- src/main/java/io/github/ktestify/match/MatchResult.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/ktestify/match/MatchResult.java b/src/main/java/io/github/ktestify/match/MatchResult.java index 0a5c09a..8b21af9 100644 --- a/src/main/java/io/github/ktestify/match/MatchResult.java +++ b/src/main/java/io/github/ktestify/match/MatchResult.java @@ -122,6 +122,8 @@ public static MatchResult noRecords() { * @since 1.1.4 */ public static MatchResult nullValue(String expected) { - return new MatchResult(false, "Record value is null (tombstone).", expected, null); + return new MatchResult(false, NULL_VALUE_MESSAGE, expected, null); } + + public static final String NULL_VALUE_MESSAGE = "Record value is null (tombstone)."; } From e7156af063cb218541b3d25a76bb94c44e34dd2a Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Fri, 2 Oct 2026 23:00:05 +0200 Subject: [PATCH 26/32] style: spotless --- .../ktestify/config/KtestifyConfig.java | 5 ++-- .../ktestify/config/SchemaRegistryConfig.java | 7 +++--- .../io/core/AbstractSynchronousConsumer.java | 3 +-- .../io/inputs/DynamicVariableFactory.java | 7 +++--- .../io/kafka/AbstractKafkaConsumer.java | 5 ++-- .../ktestify/io/kafka/KafkaRecordFetcher.java | 15 ++++++------ .../match/impl/AttributeRecordMatcher.java | 3 +-- .../match/impl/AvroFieldsRecordMatcher.java | 5 ++-- .../match/impl/AvroFileKeyRecordMatcher.java | 3 +-- .../match/impl/AvroFileRecordMatcher.java | 3 +-- .../match/impl/AvroKeyRecordMatcher.java | 3 +-- .../match/impl/FieldsRecordMatcher.java | 3 +-- .../match/impl/FileKeyRecordMatcher.java | 3 +-- .../match/impl/FileRecordMatcher.java | 3 +-- .../ktestify/match/impl/KeyRecordMatcher.java | 3 +-- .../match/impl/NoOpRecordMatcher.java | 3 +-- .../match/impl/XPathRecordMatcher.java | 3 +-- .../ktestify/match/impl/XmlRecordMatcher.java | 11 ++++----- .../ktestify/models/ConsumedRecord.java | 7 +++--- .../ktestify/plugin/PluginRegistry.java | 5 ++-- .../io/github/ktestify/utils/XMLUtils.java | 17 +++++++------- .../utils/serdes/AvroDeserializer.java | 17 +++++++------- .../ktestify/utils/serdes/AvroUtils.java | 19 ++++++++------- .../core/AbstractSynchronousConsumerTest.java | 19 ++++++++------- .../io/inputs/DynamicVariableFactoryTest.java | 11 ++++----- .../io/kafka/AvroKafkaConsumerTest.java | 15 ++++++------ .../io/kafka/RawKafkaConsumerTest.java | 19 ++++++++------- .../match/RecordMatcherFactoryTest.java | 6 ++--- .../impl/AttributeRecordMatcherTest.java | 15 ++++++------ .../match/impl/MatcherTestSupport.java | 3 +-- .../match/impl/TombstoneMatcherTest.java | 11 ++++----- .../match/impl/XmlRecordMatcherTest.java | 9 ++++---- .../io/github/ktestify/models/TopicTest.java | 4 ++-- .../ktestify/plugin/PluginRegistryTest.java | 9 ++++---- .../tests/extentions/KafkaTestExtension.java | 7 +++--- .../SchemaRegistryTestExtension.java | 3 +-- .../ktestify/utils/FieldMatcherUtilsTest.java | 4 ++-- .../AvroLogicalTypesSerializationTest.java | 13 +++++------ .../ktestify/utils/serdes/AvroUtilsTest.java | 23 +++++++++---------- 39 files changed, 144 insertions(+), 180 deletions(-) diff --git a/src/main/java/io/github/ktestify/config/KtestifyConfig.java b/src/main/java/io/github/ktestify/config/KtestifyConfig.java index a802cb1..e41e641 100644 --- a/src/main/java/io/github/ktestify/config/KtestifyConfig.java +++ b/src/main/java/io/github/ktestify/config/KtestifyConfig.java @@ -17,6 +17,8 @@ import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; +import java.io.File; +import java.util.Optional; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.Level; @@ -24,9 +26,6 @@ import org.apache.logging.log4j.core.config.Configuration; import org.apache.logging.log4j.core.config.LoggerConfig; -import java.io.File; -import java.util.Optional; - /** * Main configuration class for ktestify framework. * diff --git a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java index a919e7c..ca1719b 100644 --- a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java +++ b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java @@ -16,14 +16,13 @@ package io.github.ktestify.config; import com.typesafe.config.Config; -import lombok.Getter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Properties; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Schema Registry configuration. diff --git a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java index fa0c033..241d600 100644 --- a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java +++ b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java @@ -21,10 +21,9 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import lombok.extern.slf4j.Slf4j; - import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; /** * Thin coordinator that wires a {@link RequestResponseClient} (transport) with a {@link RecordMatcher} (assertion) for diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java index f283df4..da00d9e 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java @@ -19,14 +19,13 @@ import io.github.ktestify.io.inputs.types.EnvironmentVariable; import io.github.ktestify.io.inputs.types.RandomVariable; import io.github.ktestify.io.inputs.types.TimestampVariable; -import lombok.experimental.UtilityClass; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import lombok.experimental.UtilityClass; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Thread-safe registry of {@link DynamicVariable} implementations, keyed by variable name. diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java index ca7d312..1966b1c 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java @@ -23,11 +23,10 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.models.Topic; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.Consumer; - import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.Consumer; /** * Thin coordinator that wires a {@link KafkaRecordFetcher} (transport) with a {@link RecordMatcher} (assertion) and diff --git a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java index f782cc9..06cded0 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -15,12 +15,19 @@ */ package io.github.ktestify.io.kafka; +import static io.github.ktestify.constants.LogMessagesConstants.*; + import io.github.ktestify.config.FrameworkConfig; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.FetchException; import io.github.ktestify.io.core.RecordFetcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.models.MatchedRecord; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.Consumer; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -29,14 +36,6 @@ import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.errors.WakeupException; -import java.time.Duration; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import static io.github.ktestify.constants.LogMessagesConstants.*; - /** * Kafka implementation of {@link RecordFetcher}. * diff --git a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java index a3c64ba..b43b6d2 100644 --- a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java @@ -20,9 +20,8 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import lombok.extern.slf4j.Slf4j; - import java.util.*; +import lombok.extern.slf4j.Slf4j; /** * Generic matcher that asserts one or more {@link ConsumedRecord#getAttributes()} entries against the expected values diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java index c48a99d..5639706 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java @@ -22,12 +22,11 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.serdes.AvroUtils; -import lombok.extern.slf4j.Slf4j; -import org.apache.avro.generic.GenericRecord; - import java.util.ArrayList; import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.avro.generic.GenericRecord; /** * Matches a specific field (or set of fields) within an Avro record, using either an inline expected value or an diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java index 8e65328..a4044af 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -22,11 +22,10 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.serdes.AvroUtils; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; -import java.util.List; - /** * Asserts both the record key and Avro value against a configured expected key and expected JSON * file. Supports optional field exclusion via {@link MatchContext#getExcludedFields()}, like diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java index a751c8e..7d45259 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java @@ -22,11 +22,10 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.serdes.AvroUtils; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; -import java.util.List; - /** * Compares an Avro record's value against the content of an expected JSON file using smart matching. Supports optional * field exclusion via {@link MatchContext#getExcludedFields()}. diff --git a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java index 9dfeb8e..ccae0ff 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -20,11 +20,10 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; -import java.util.List; - /** * Asserts that the Avro record key equals the expected key in {@link MatchContext#getMatchKey()}. * diff --git a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java index ed5e1c1..38d5bf2 100644 --- a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java @@ -22,9 +22,8 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FieldMatcherUtils; import io.github.ktestify.utils.FileUtils; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * Matches a fixed-position field extracted from a positional record against either an inline expected value diff --git a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java index 2674bf3..158a491 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -22,9 +22,8 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.StringDiffUtils; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * Compares both the record key and value against a configured expected key and expected file content. diff --git a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java index 34295a3..0df73c1 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java @@ -22,9 +22,8 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.StringDiffUtils; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * Compares the String value of the first consumed record against the content of an expected file. diff --git a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java index 34b545d..de835af 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -20,9 +20,8 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * Asserts that the record key equals the expected key defined in {@link MatchContext#getMatchKey()}. diff --git a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java index 4f06ccb..9b71765 100644 --- a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java @@ -19,9 +19,8 @@ import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * A {@link RecordMatcher} that always passes. diff --git a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java index 72cc4ae..e862e77 100644 --- a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java @@ -22,9 +22,8 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.XMLUtils; -import lombok.extern.slf4j.Slf4j; - import java.util.List; +import lombok.extern.slf4j.Slf4j; /** * Compares the record value as XML against an expected file using a set of XPath expressions. All XPath comparisons diff --git a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java index 505df9c..09a5bd9 100644 --- a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java @@ -22,17 +22,16 @@ import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.XMLUtils; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; import lombok.extern.slf4j.Slf4j; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.helpers.DefaultHandler; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.List; - /** * Compares the record value as XML against an expected XML file. Supports optional element exclusion via * {@link MatchContext#getExcludedFields()}. diff --git a/src/main/java/io/github/ktestify/models/ConsumedRecord.java b/src/main/java/io/github/ktestify/models/ConsumedRecord.java index d865ce3..94c1c48 100644 --- a/src/main/java/io/github/ktestify/models/ConsumedRecord.java +++ b/src/main/java/io/github/ktestify/models/ConsumedRecord.java @@ -15,15 +15,14 @@ */ package io.github.ktestify.models; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; import lombok.Builder; import lombok.Value; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.common.header.Header; -import java.time.Instant; -import java.util.Collections; -import java.util.Map; - /** * Immutable value object representing a single record that has been fetched from any IO source (Kafka, IBM MQ, etc.). * diff --git a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java index a9d91dc..44aa50d 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -16,15 +16,14 @@ package io.github.ktestify.plugin; import io.github.ktestify.exceptions.PluginException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Discovers, loads, initializes, and holds all active {@link KtestifyPlugin} instances for the current JVM run. diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index 77b4372..8e9f395 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -15,6 +15,14 @@ */ package io.github.ktestify.utils; +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,15 +34,6 @@ import org.xmlunit.builder.DiffBuilder; import org.xmlunit.diff.*; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; -import java.io.StringReader; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. * diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java index 29c23a0..2a0780f 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java @@ -15,6 +15,14 @@ */ package io.github.ktestify.utils.serdes; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; import org.apache.avro.Conversions.DecimalConversion; import org.apache.avro.Conversions.UUIDConversion; import org.apache.avro.LogicalType; @@ -27,15 +35,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.*; -import java.util.stream.Collectors; - /** * Deserializes Avro {@link GenericRecord} instances into plain Java {@link Map} representations, handling all Avro * logical types (date, time, timestamp, decimal, UUID, …). diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index 3efce05..cbad55b 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -15,18 +15,12 @@ */ package io.github.ktestify.utils.serdes; +import static io.github.ktestify.constants.LogMessagesConstants.*; + import com.google.gson.*; import com.google.gson.reflect.TypeToken; import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.exceptions.ProducerException; -import lombok.NonNull; -import org.apache.avro.LogicalTypes; -import org.apache.avro.Schema; -import org.apache.avro.generic.GenericData; -import org.apache.avro.generic.GenericRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.MathContext; @@ -40,8 +34,13 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.*; - -import static io.github.ktestify.constants.LogMessagesConstants.*; +import lombok.NonNull; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Utility class for Apache Avro operations, providing comprehensive functionality for: diff --git a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java index 1f85f09..13d3bdb 100644 --- a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java @@ -15,26 +15,25 @@ */ package io.github.ktestify.io.core; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + import io.github.ktestify.exceptions.ConsumerException; import io.github.ktestify.exceptions.FetchException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; @DisplayName("AbstractSynchronousConsumer") class AbstractSynchronousConsumerTest { diff --git a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java index ad9d5ac..8020eb9 100644 --- a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java @@ -15,18 +15,17 @@ */ package io.github.ktestify.io.inputs; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - public class DynamicVariableFactoryTest { @Mock diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java index 5a47eac..a4209d6 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java @@ -15,6 +15,9 @@ */ package io.github.ktestify.io.kafka; +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; +import static org.junit.jupiter.api.Assertions.*; + import io.confluent.kafka.serializers.KafkaAvroDeserializer; import io.confluent.kafka.serializers.KafkaAvroSerializer; import io.confluent.kafka.serializers.KafkaAvroSerializerConfig; @@ -25,6 +28,10 @@ import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; import io.github.ktestify.tests.extentions.SchemaRegistryTestExtension; +import java.net.URL; +import java.util.List; +import java.util.Properties; +import java.util.UUID; import org.apache.avro.Schema; import org.apache.avro.SchemaBuilder; import org.apache.avro.generic.GenericData; @@ -39,14 +46,6 @@ import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; -import java.net.URL; -import java.util.List; -import java.util.Properties; -import java.util.UUID; - -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; -import static org.junit.jupiter.api.Assertions.*; - /** * Integration tests for {@link AvroKafkaConsumer}. * diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java index 59897ff..17a6442 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java @@ -15,12 +15,21 @@ */ package io.github.ktestify.io.kafka; +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; +import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_XML; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConsumerException; import io.github.ktestify.io.kafka.impl.RawKafkaConsumer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Properties; +import java.util.UUID; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; @@ -28,16 +37,6 @@ import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Properties; -import java.util.UUID; - -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE; -import static io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_XML; -import static org.junit.jupiter.api.Assertions.*; - /** * Integration tests for {@link RawKafkaConsumer}. * diff --git a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java index 2989b45..5d485c9 100644 --- a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java +++ b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java @@ -15,6 +15,9 @@ */ package io.github.ktestify.match; +import static io.github.ktestify.match.RecordMatcherFactory.*; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import org.apache.avro.generic.GenericRecord; @@ -25,9 +28,6 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; -import static io.github.ktestify.match.RecordMatcherFactory.*; -import static org.junit.jupiter.api.Assertions.*; - @DisplayName("RecordMatcherFactory") class RecordMatcherFactoryTest { diff --git a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java index ae02892..0a10bea 100644 --- a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java @@ -15,20 +15,19 @@ */ package io.github.ktestify.match.impl; +import static io.github.ktestify.match.impl.MatcherTestSupport.ctxWithAttributes; +import static io.github.ktestify.match.impl.MatcherTestSupport.rawRecordWithAttributes; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - import java.util.Collections; import java.util.List; import java.util.Map; - -import static io.github.ktestify.match.impl.MatcherTestSupport.ctxWithAttributes; -import static io.github.ktestify.match.impl.MatcherTestSupport.rawRecordWithAttributes; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; @DisplayName("AttributeRecordMatcher") class AttributeRecordMatcherTest { diff --git a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java index efc7e30..152bc42 100644 --- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java +++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java @@ -19,13 +19,12 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import org.apache.avro.generic.GenericRecord; - import java.net.URL; import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; +import org.apache.avro.generic.GenericRecord; /** * Shared test helpers for {@link RecordMatcher} unit tests. Keeps each test class lean: no boilerplate record diff --git a/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java index c9d449b..79a6ba6 100644 --- a/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java @@ -15,19 +15,18 @@ */ package io.github.ktestify.match.impl; +import static io.github.ktestify.match.impl.MatcherTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.List; -import java.util.Map; - -import static io.github.ktestify.match.impl.MatcherTestSupport.*; -import static org.junit.jupiter.api.Assertions.*; - /** * Checks that every matcher handles a tombstone (record with a {@code null} value) the same way: value matchers fail * cleanly without throwing, key-only and attribute matchers ignore the value. diff --git a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java index baa3eed..21d18ce 100644 --- a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java @@ -15,18 +15,17 @@ */ package io.github.ktestify.match.impl; +import static io.github.ktestify.match.impl.MatcherTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.List; - -import static io.github.ktestify.match.impl.MatcherTestSupport.*; -import static org.junit.jupiter.api.Assertions.*; - @DisplayName("XmlRecordMatcher") class XmlRecordMatcherTest { diff --git a/src/test/java/io/github/ktestify/models/TopicTest.java b/src/test/java/io/github/ktestify/models/TopicTest.java index 097793a..4e2cd3b 100644 --- a/src/test/java/io/github/ktestify/models/TopicTest.java +++ b/src/test/java/io/github/ktestify/models/TopicTest.java @@ -15,13 +15,13 @@ */ package io.github.ktestify.models; +import static org.junit.jupiter.api.Assertions.*; + import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; import org.junit.jupiter.api.*; -import static org.junit.jupiter.api.Assertions.*; - /** * Unit tests for {@link Topic#validateTopic(Topic)}: focusing on the namespace auto-injection logic: * diff --git a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java index 43d2100..dc1a347 100644 --- a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java +++ b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java @@ -15,12 +15,11 @@ */ package io.github.ktestify.plugin; +import static org.junit.jupiter.api.Assertions.*; + import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.PluginException; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.io.TempDir; - import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -30,8 +29,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; /** * Unit tests for {@link PluginRegistry} and {@link KtestifyPlugin}. diff --git a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java index 27b3717..3c71fd4 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java @@ -15,6 +15,9 @@ */ package io.github.ktestify.tests.extentions; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.admin.NewTopic; @@ -25,10 +28,6 @@ import org.testcontainers.kafka.KafkaContainer; import org.testcontainers.utility.DockerImageName; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.TimeUnit; - /** * JUnit 5 extension that starts a Kafka broker via Testcontainers for the duration of the test class, and exposes * static helpers used by the tests. diff --git a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java index a3ae801..7b8f78a 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java @@ -17,6 +17,7 @@ import io.confluent.kafka.schemaregistry.client.CachedSchemaRegistryClient; import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; @@ -25,8 +26,6 @@ import org.testcontainers.containers.Network; import org.testcontainers.utility.DockerImageName; -import java.util.Map; - /** * JUnit 5 extension that starts a Confluent Schema Registry container via Testcontainers for the duration of the test * class, and exposes static helpers used by the tests. diff --git a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java index 5f254ca..71a7b12 100644 --- a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java @@ -15,11 +15,11 @@ */ package io.github.ktestify.utils; +import static org.junit.jupiter.api.Assertions.assertThrows; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertThrows; - public class FieldMatcherUtilsTest { @Test void testGetLine() { diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java index ff93101..3d105ef 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java @@ -15,11 +15,7 @@ */ package io.github.ktestify.utils.serdes; -import org.apache.avro.Schema; -import org.apache.avro.generic.GenericData; -import org.apache.avro.generic.GenericRecord; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; import java.nio.ByteBuffer; import java.time.Instant; @@ -28,8 +24,11 @@ import java.time.LocalTime; import java.util.LinkedHashMap; import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; /** Regression tests for Avro logical types going through {@link AvroDeserializer} and {@link AvroUtils}. */ @DisplayName("Avro logical types to JSON") diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index 19c672c..d31e2c4 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -15,8 +15,19 @@ */ package io.github.ktestify.utils.serdes; +import static org.junit.jupiter.api.Assertions.*; + import com.google.gson.*; import io.github.ktestify.exceptions.ComparisonException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.nio.ByteBuffer; +import java.text.ParseException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; import lombok.extern.slf4j.Slf4j; import org.apache.avro.LogicalTypes; import org.apache.avro.Schema; @@ -28,18 +39,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.nio.ByteBuffer; -import java.text.ParseException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - @Slf4j class AvroUtilsTest { From aba34d9223e88cdfbf67863f933d33dc269dc90b Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Sun, 4 Oct 2026 23:20:29 +0200 Subject: [PATCH 27/32] fix: null pointer exception on empty TimestampVariable when format is null --- .../io/github/ktestify/io/inputs/types/TimestampVariable.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java index b29a522..b4ef8d9 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java @@ -27,6 +27,9 @@ public String getName() { @Override public String process(String format) { + if(format == null || format.isEmpty()) { + return process(); + } return LocalDateTime.now().format(DateTimeFormatter.ofPattern(format)); } From b5c6c7a50c914d26fe03292cb4880ff1aea9ae20 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Mon, 5 Oct 2026 00:19:20 +0200 Subject: [PATCH 28/32] ci: update CI workflow to use reusable Maven CI and add manual integration test option --- .github/workflows/ci.yml | 304 ++------------------------------------- pom.xml | 2 +- 2 files changed, 15 insertions(+), 291 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd5b225..a60ebd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,8 @@ -# ============================================================ -# CI — Continuous Integration -# -# Triggered on: -# • push → main (every merge — post-merge validation) -# • pull_request → main (every PR — pre-merge validation) -# -# Restricting push to main avoids duplicate job runs: when a -# feature branch has an open PR, GitHub fires both push and -# pull_request events. Limiting push to main means each commit -# is evaluated exactly once. -# -# Two independent jobs run in parallel: -# -# lint → Spotless code-style check -# build → Compile + tests (unit + Testcontainers) + JaCoCo -# -# A synthetic `ci-success` job aggregates the two results so -# branch-protection rules only need to track one required check. -# ============================================================ name: CI on: push: - branches: [ main ] # feature branches are covered by pull_request — avoids duplicate runs + branches: [ main ] paths-ignore: - "**.md" - "LICENSE" @@ -37,284 +17,28 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/pull_request_template.md" - ".github/SECURITY.md" + workflow_dispatch: + inputs: + run-integration-tests: + description: "Run integration tests (manual opt-in, useful for PR branches)" + type: boolean + default: false -# Cancel in-progress runs for the same branch/PR (keep only the latest) concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -env: - JAVA_VERSION: "25" - MAVEN_OPTS: >- - -Xmx2048m - -XX:+EnableDynamicAgentLoading - -Dmaven.repo.local=${{ github.workspace }}/.m2/repository - -# Minimal default permissions — each job declares only what it needs permissions: contents: read -# ────────────────────────────────────────────────────────────── jobs: - - # ── 1. Code Style ───────────────────────────────────────── - lint: - name: 🎨 Code Style (Spotless) - runs-on: ubuntu-latest - permissions: - contents: read # checkout only - - steps: - - name: Checkout - uses: actions/checkout@v7 # v6: credentials stored in $RUNNER_TEMP, not .git/config - - - name: Set up Java ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - cache: maven - - - name: Spotless — check formatting - run: mvn spotless:check --no-transfer-progress - - # ── 2. Build, Test & Coverage ──────────────────────────── - build: - name: 🔨 Build, Test & Coverage - runs-on: ubuntu-latest - permissions: - contents: read # checkout only - pull-requests: write # post / update the sticky CI report comment - - steps: - - name: Checkout - uses: actions/checkout@v7 # v6: credentials stored in $RUNNER_TEMP, not .git/config - - - name: Set up Java ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - cache: maven - - # Testcontainers: disable Ryuk to avoid permission issues on GH runners - - name: Build, run tests & enforce coverage (≥ 70 %) - run: mvn verify --no-transfer-progress -Dspotless.check.skip=true - env: - TESTCONTAINERS_RYUK_DISABLED: "true" - - # Always upload the HTML + XML JaCoCo report so it is available - # for SonarCloud and for manual inspection - - name: Upload JaCoCo HTML report - if: always() - uses: actions/upload-artifact@v7 - with: - name: jacoco-html-${{ github.run_id }} - path: target/site/jacoco/ - retention-days: 14 - - # Upload the binary .exec — kept for future tooling integration - - name: Upload JaCoCo exec - if: > - github.ref == 'refs/heads/main' || - github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: jacoco-exec-${{ github.run_id }} - path: target/jacoco.exec - retention-days: 7 - - # Upload Surefire XML reports so GitHub can parse test results - - name: Upload Surefire reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: surefire-reports-${{ github.run_id }} - path: target/surefire-reports/ - retention-days: 7 - - # ── Generate CI report ──────────────────────────────── - # Parses Surefire XML + JaCoCo XML and produces a single - # Markdown report written to: - # • $GITHUB_STEP_SUMMARY → visible in the Actions run page - # • /tmp/ci-report.md → picked up by the PR comment step - - name: 📊 Generate CI report (tests + coverage) - if: always() - run: | - python3 << 'PYEOF' - import xml.etree.ElementTree as ET - import glob, os - - # ── Surefire results ───────────────────────────── - # WHY we count elements instead of reading the - # root attribute: - # - # JUnit 5 with @Nested classes causes surefire to write - # *nested* elements inside the root one. - # The root's `tests` attribute then reflects only direct - # (non-nested) test methods, producing a count far below - # what Maven itself reports (e.g. 68 vs 613). - # - # Counting every descendant recursively gives - # the true total regardless of nesting depth. - total = failures = errors = skipped = 0 - failed_list = [] - - for path in sorted(glob.glob('target/surefire-reports/TEST-*.xml')): - try: - root = ET.parse(path).getroot() - suite_name = root.get('name', path) - - for tc in root.findall('.//testcase'): - total += 1 - if tc.find('failure') is not None: - failures += 1 - failed_list.append(f"{tc.get('classname', suite_name)}#{tc.get('name', '?')}") - elif tc.find('error') is not None: - errors += 1 - failed_list.append(f"{tc.get('classname', suite_name)}#{tc.get('name', '?')}") - elif tc.find('skipped') is not None: - skipped += 1 - except Exception as e: - print(f'Warning: could not parse {path}: {e}') - - passed = total - failures - errors - skipped - t_status = '✅' if failures + errors == 0 else '❌' - - # ── JaCoCo coverage ────────────────────────────── - line_pct = branch_pct = method_pct = 0.0 - line_cov = line_tot = branch_cov = branch_tot = method_cov = method_tot = 0 - cov_ok = False - - try: - root = ET.parse('target/site/jacoco/jacoco.xml').getroot() - for counter in root.findall('counter'): - ctype = counter.get('type') - covered = int(counter.get('covered', 0)) - missed = int(counter.get('missed', 0)) - tot = covered + missed - pct = (covered / tot * 100) if tot > 0 else 0.0 - if ctype == 'LINE': line_pct, line_cov, line_tot = pct, covered, tot - elif ctype == 'BRANCH': branch_pct, branch_cov, branch_tot = pct, covered, tot - elif ctype == 'METHOD': method_pct, method_cov, method_tot = pct, covered, tot - cov_ok = True - except Exception as e: - print(f'Warning: could not parse JaCoCo XML: {e}') - - threshold = 70.0 - c_status = '✅' if line_pct >= threshold else '⚠️' - - # ── Build the Markdown report ───────────────────── - lines = [] - lines += [f'## {t_status} Test Results\n'] - lines += ['| | Metric | Count |', '|:---:|:---|---:|'] - lines += [f'| ✅ | Passed | {passed} |'] - lines += [f'| ❌ | Failed | {failures + errors} |'] - lines += [f'| ⏭️ | Skipped | {skipped} |'] - lines += [f'| 📊 | **Total** | **{total}** |'] - - if failed_list: - lines += ['', '### ❌ Failing Tests'] - for t in failed_list[:15]: - lines += [f'- `{t}`'] - if len(failed_list) > 15: - lines += [f'- *… and {len(failed_list) - 15} more*'] - - lines += [''] - - if cov_ok: - lines += [f'## {c_status} Coverage\n'] - lines += ['| | Type | Coverage | Covered / Total |', '|:---:|:---|---:|---:|'] - lines += [f'| 📏 | Lines | **{line_pct:.1f}%** | {line_cov} / {line_tot} |'] - lines += [f'| 🌿 | Branches | {branch_pct:.1f}% | {branch_cov} / {branch_tot} |'] - lines += [f'| 🔧 | Methods | {method_pct:.1f}% | {method_cov} / {method_tot} |'] - if line_pct < threshold: - lines += ['', f'> ⚠️ Line coverage **{line_pct:.1f}%** is below the required **{threshold:.0f}%** threshold'] - else: - lines += ['## ⚠️ Coverage report not available'] - - report = '\n'.join(lines) + '\n' - - # Write to Actions job summary - with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f: - f.write(report) - - # Write to file for the PR comment step below - with open('/tmp/ci-report.md', 'w') as f: - f.write(report) - - print(report) - PYEOF - - # ── Sticky PR comment ───────────────────────────────── - # Posts a single comment that is updated (not duplicated) on - # every push to the same PR using a hidden HTML marker. - # Only runs on pull_request events — skipped on direct pushes. - - name: 💬 Post CI report to PR - if: ${{ always() && github.event_name == 'pull_request' }} - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - if (!fs.existsSync('/tmp/ci-report.md')) { - console.log('CI report file not found — skipping comment.'); - return; - } - - const marker = ''; - const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; - const content = fs.readFileSync('/tmp/ci-report.md', 'utf8'); - const body = `${marker}\n${content}\n---\n*🔄 [CI run #${process.env.GITHUB_RUN_NUMBER}](${runUrl}) · ${new Date().toUTCString()}*`; - - // Find an existing comment with our marker - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - - const existing = comments.find(c => c.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - console.log(`✅ Updated CI report comment #${existing.id}`); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - console.log('✅ Created new CI report comment'); - } - - # ── 3. Aggregate status (single required check) ─────────── - ci-success: - name: ✅ CI passed - runs-on: ubuntu-latest - needs: [ lint, build ] - if: always() - permissions: - contents: read - steps: - - name: Evaluate results - run: | - lint="${{ needs.lint.result }}" - build="${{ needs.build.result }}" - echo "lint → $lint" - echo "build → $build" - if [[ "$lint" != "success" || "$build" != "success" ]]; then - echo "❌ One or more jobs failed." - exit 1 - fi - echo "✅ All CI jobs passed." + ci: + name: Maven CI + uses: ktestify/ktestify/.github/workflows/reusable-maven-ci.yml@main + with: + java-version: "25" + run-integration-tests: ${{ github.event_name == 'workflow_dispatch' && inputs.run-integration-tests || false }} + maven-opts: "" diff --git a/pom.xml b/pom.xml index 3d58ff3..f109a1b 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.github.ktestify ktestify-parent - 1.0.3-SNAPSHOT + 1.0.4 From d308e7c892a5a3a542d8808fa567110b87cd9eae Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Mon, 5 Oct 2026 00:21:52 +0200 Subject: [PATCH 29/32] (feat/bug-fixes) style: spotless --- .../io/github/ktestify/io/inputs/types/TimestampVariable.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java index b4ef8d9..166bbed 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java @@ -27,7 +27,7 @@ public String getName() { @Override public String process(String format) { - if(format == null || format.isEmpty()) { + if (format == null || format.isEmpty()) { return process(); } return LocalDateTime.now().format(DateTimeFormatter.ofPattern(format)); From 372cc845006381c11b19f0e705c8513ae99a87b6 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Mon, 5 Oct 2026 00:26:25 +0200 Subject: [PATCH 30/32] ci: fix workflow paths in CI and dependency review configurations --- .github/workflows/ci.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a60ebd8..b346c7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ permissions: jobs: ci: name: Maven CI - uses: ktestify/ktestify/.github/workflows/reusable-maven-ci.yml@main + uses: ktestify/.github/.github/workflows/reusable-maven-ci.yml@main with: java-version: "25" run-integration-tests: ${{ github.event_name == 'workflow_dispatch' && inputs.run-integration-tests || false }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 29e16fa..8fa4ea9 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,4 +20,4 @@ permissions: jobs: review: - uses: ktestify/.github/.github/workflows/reusable-dependency-review.yml@main + uses: ktestify/.github/workflows/reusable-dependency-review.yml@main From 6bd9f190867e36f00fc290578b6ea9cbbdc628c3 Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Mon, 5 Oct 2026 19:36:16 +0200 Subject: [PATCH 31/32] fix: update dependency review workflow path --- .github/workflows/dependency-review.yml | 2 +- .../io/inputs/types/TimestampVariableTest.java | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 8fa4ea9..29e16fa 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,4 +20,4 @@ permissions: jobs: review: - uses: ktestify/.github/workflows/reusable-dependency-review.yml@main + uses: ktestify/.github/.github/workflows/reusable-dependency-review.yml@main diff --git a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java index d66499f..85d8d06 100644 --- a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java @@ -15,16 +15,16 @@ */ package io.github.ktestify.io.inputs.types; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + public class TimestampVariableTest { private TimestampVariable timestampVariable; @@ -146,11 +146,11 @@ public void testProcessWithInvalidFormat() { public void testProcessWithNullFormat() { try (MockedStatic mockedDateTime = Mockito.mockStatic(LocalDateTime.class)) { mockedDateTime.when(LocalDateTime::now).thenReturn(FIXED_DATETIME); + assertEquals( + FIXED_DATETIME.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + timestampVariable.process(), + "Default process method should use ISO_LOCAL_DATE_TIME format"); - assertThrows( - NullPointerException.class, - () -> timestampVariable.process(null), - "Should throw NullPointerException for null format"); } } From 050876552cd2e0d4ca7a70e197280fb7cca1dc1f Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Mon, 5 Oct 2026 19:38:18 +0200 Subject: [PATCH 32/32] style: spotless --- .../io/inputs/types/TimestampVariableTest.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java index 85d8d06..c2d02ff 100644 --- a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java @@ -15,16 +15,15 @@ */ package io.github.ktestify.io.inputs.types; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import static org.junit.jupiter.api.Assertions.assertEquals; - public class TimestampVariableTest { private TimestampVariable timestampVariable; @@ -150,7 +149,6 @@ public void testProcessWithNullFormat() { FIXED_DATETIME.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), timestampVariable.process(), "Default process method should use ISO_LOCAL_DATE_TIME format"); - } }