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..6fddb89 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java @@ -22,11 +22,8 @@ import io.github.ktestify.match.MatchResult; 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; /** * Thin coordinator that wires a {@link KafkaRecordFetcher} (transport) with a {@link RecordMatcher} (assertion) and @@ -66,28 +63,6 @@ protected AbstractKafkaConsumer(ConsumerContext context, RecordMatcher matcher.getClass().getSimpleName()); } - /** - * Legacy convenience constructor for callers that previously passed topic + consumer + properties. - * - * @param topic the topic to consume from - * @param consumer the Kafka consumer instance - * @param properties the consumer properties map - * @param matcher the assertion strategy - * @deprecated Build a {@link ConsumerContext} and use {@link #AbstractKafkaConsumer(ConsumerContext, - * RecordMatcher)} instead. - */ - @Deprecated - protected AbstractKafkaConsumer( - Topic topic, Consumer consumer, Map properties, RecordMatcher matcher) { - this( - ConsumerContext.builder() - .topic(topic) - .consumer(consumer) - .properties(properties) - .build(), - matcher); - } - /** * Fetches records from Kafka, then asserts them with the configured matcher. * @@ -137,6 +112,7 @@ protected MatchContext buildMatchContext() { .matchFilePaths(context.getMatchFilePaths()) .excludedFields(context.getExcludedFields()) .strictMatching(false) + .keyMatchStrategy(context.getKeyMatchStrategy()) .build(); } } diff --git a/src/main/java/io/github/ktestify/io/kafka/ConsumerContext.java b/src/main/java/io/github/ktestify/io/kafka/ConsumerContext.java index 0dec8a0..47ff992 100644 --- a/src/main/java/io/github/ktestify/io/kafka/ConsumerContext.java +++ b/src/main/java/io/github/ktestify/io/kafka/ConsumerContext.java @@ -17,6 +17,7 @@ import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConsumerException; +import io.github.ktestify.match.KeyMatchStrategy; import io.github.ktestify.models.Topic; import java.util.Collections; import java.util.List; @@ -31,6 +32,7 @@ public final class ConsumerContext { private final Map properties; private final Consumer consumer; private final String expectedRecordKey; + private final KeyMatchStrategy keyMatchStrategy; private final String matchMethod; private final List matchFilePaths; private final List excludedFields; @@ -45,6 +47,7 @@ private ConsumerContext( Map properties, Consumer consumer, String expectedRecordKey, + KeyMatchStrategy keyMatchStrategy, String matchMethod, List matchFilePaths, List excludedFields, @@ -57,6 +60,7 @@ private ConsumerContext( this.properties = properties; this.consumer = consumer; this.expectedRecordKey = expectedRecordKey; + this.keyMatchStrategy = keyMatchStrategy != null ? keyMatchStrategy : KeyMatchStrategy.EXACT; this.matchMethod = matchMethod; this.matchFilePaths = matchFilePaths != null ? matchFilePaths : Collections.emptyList(); this.excludedFields = excludedFields != null ? excludedFields : Collections.emptyList(); @@ -85,6 +89,7 @@ public static final class Builder { private Map properties; private Consumer consumer; private String expectedRecordKey; + private KeyMatchStrategy keyMatchStrategy; private String matchMethod; private List matchFilePaths; private List excludedFields; @@ -114,6 +119,21 @@ public Builder expectedRecordKey(String expectedRecordKey) { return this; } + /** + * Sets the strategy used to compare {@link #expectedRecordKey} against the actual record key during the + * fetch-time pre-filter in {@code KafkaRecordFetcher.passesKeyFilter()}. + * + *

Defaults to {@link KeyMatchStrategy#EXACT} when not set, preserving backward compatibility. + * + * @param keyMatchStrategy the match strategy, or {@code null} to use the default + * @return this builder + * @since 1.1.5 + */ + public Builder keyMatchStrategy(KeyMatchStrategy keyMatchStrategy) { + this.keyMatchStrategy = keyMatchStrategy; + return this; + } + public Builder matchMethod(String matchMethod) { this.matchMethod = matchMethod; return this; @@ -196,6 +216,7 @@ public ConsumerContext build() { validatedProps, validatedConsumer, expectedRecordKey, + keyMatchStrategy, matchMethod, matchFilePaths, excludedFields, 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..9005cc0 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -24,11 +24,7 @@ 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.*; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -337,7 +333,7 @@ private void registerAsMatched(ConsumedRecord record) { /** * Returns {@code true} if no key-filter is configured, or if the record key matches the expected key from the - * context / properties. + * context / properties using the configured {@link io.github.ktestify.match.KeyMatchStrategy}. */ private boolean passesKeyFilter(ConsumerRecord record) { // Context takes priority over properties map @@ -351,7 +347,7 @@ private boolean passesKeyFilter(ConsumerRecord record) { } String recordKey = record.key() != null ? record.key().toString() : null; - if (expectedKey.equals(recordKey)) { + if (context.getKeyMatchStrategy().matches(expectedKey, recordKey)) { log.info(MESSAGE_CONSUMER_RECORD_MATCHES_EXPECTED_KEY, expectedKey); return true; } diff --git a/src/main/java/io/github/ktestify/match/KeyMatchStrategy.java b/src/main/java/io/github/ktestify/match/KeyMatchStrategy.java new file mode 100644 index 0000000..867ec41 --- /dev/null +++ b/src/main/java/io/github/ktestify/match/KeyMatchStrategy.java @@ -0,0 +1,140 @@ +/* + * 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; + +/** + * Strategies for comparing a record key against an expected key. + * + *

Used in two places: + * + *

    + *
  • {@code KafkaRecordFetcher.passesKeyFilter()} as a pre-filter during the Kafka poll loop + *
  • {@code KeyRecordMatcher}, {@code FileKeyRecordMatcher}, {@code AvroKeyRecordMatcher}, and + * {@code AvroFileKeyRecordMatcher} as the post-fetch assertion + *
+ * + *

The default strategy is {@link #EXACT}, which preserves the original {@code String.equals()} behavior. Other + * strategies allow matching dynamically generated keys by prefix, suffix, substring, or regular expression. + * + * @since 1.1.4 + */ +public enum KeyMatchStrategy { + + /** + * Exact equality: {@code expected.equals(actual)}. + * + *

This is the default and preserves backward compatibility for feature files that do not specify a + * {@code keyMatchStrategy} column. + * + * @since 1.1.4 + */ + EXACT { + @Override + public boolean matches(String expected, String actual) { + return expected != null && expected.equals(actual); + } + }, + + /** + * Substring match: {@code actual.contains(expected)}. + * + *

Useful when the record key contains a known fragment embedded in a larger dynamically generated value. + * + * @since 1.1.4 + */ + CONTAINS { + @Override + public boolean matches(String expected, String actual) { + return expected != null && actual != null && actual.contains(expected); + } + }, + + /** + * Prefix match: {@code actual.startsWith(expected)}. + * + *

Useful when the record key starts with a known prefix followed by a dynamically generated suffix (e.g. + * {@code ORD-}). + * + * @since 1.1.4 + */ + STARTS_WITH { + @Override + public boolean matches(String expected, String actual) { + return expected != null && actual != null && actual.startsWith(expected); + } + }, + + /** + * Suffix match: {@code actual.endsWith(expected)}. + * + *

Useful when the record key ends with a known suffix preceded by a dynamically generated prefix. + * + * @since 1.1.4 + */ + ENDS_WITH { + @Override + public boolean matches(String expected, String actual) { + return expected != null && actual != null && actual.endsWith(expected); + } + }, + + /** + * Regular expression match: {@code actual.matches(expected)}. + * + *

The {@code expected} string is interpreted as a Java regular expression. Useful for arbitrary patterns such as + * {@code ORD-\d{6}} that cannot be expressed with prefix, suffix, or substring matching. + * + * @since 1.1.4 + */ + REGEX { + @Override + public boolean matches(String expected, String actual) { + return expected != null && actual != null && actual.matches(expected); + } + }; + + /** + * Tests whether the {@code actual} record key satisfies this strategy given the {@code expected} key. + * + * @param expected the expected key value (or pattern for {@link #REGEX}) + * @param actual the actual record key, may be {@code null} when the Kafka record has no key + * @return {@code true} if the actual key matches according to this strategy + * @since 1.1.4 + */ + public abstract boolean matches(String expected, String actual); + + /** + * Parses a strategy name from a DataTable column value. + * + *

Matching is case-insensitive and tolerant of hyphens, underscores, and spaces. For example, + * {@code "starts_with"}, {@code "starts-with"}, and {@code "STARTS WITH"} all resolve to {@link #STARTS_WITH}. + * + * @param value the raw column value, may be {@code null} or blank + * @return the parsed strategy, or {@link #EXACT} when the value is {@code null}, blank, or unrecognized + * @since 1.1.4 + */ + public static KeyMatchStrategy fromString(String value) { + if (value == null || value.isBlank()) { + return EXACT; + } + String normalized = value.trim().toUpperCase().replace('-', '_').replace(' ', '_'); + try { + return KeyMatchStrategy.valueOf(normalized); + } catch (IllegalArgumentException e) { + return EXACT; + } + } +} diff --git a/src/main/java/io/github/ktestify/match/MatchContext.java b/src/main/java/io/github/ktestify/match/MatchContext.java index 70710aa..edc55b1 100644 --- a/src/main/java/io/github/ktestify/match/MatchContext.java +++ b/src/main/java/io/github/ktestify/match/MatchContext.java @@ -73,6 +73,18 @@ public class MatchContext { /** Expected value for {@link #matchKey}. */ String matchValue; + /** + * Strategy used to compare {@link #matchKey} against the actual record key in key-related matchers + * ({@code KeyRecordMatcher}, {@code FileKeyRecordMatcher}, {@code AvroKeyRecordMatcher}, + * {@code AvroFileKeyRecordMatcher}). + * + *

Defaults to {@link KeyMatchStrategy#EXACT}, preserving the original exact-equality behavior. + * + * @since 1.1.5 + */ + @Builder.Default + KeyMatchStrategy keyMatchStrategy = KeyMatchStrategy.EXACT; + /** * Multiple key/value pairs for multi-field inline matching. * 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..987d78b 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -54,13 +54,13 @@ 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()); - boolean keyMatches = expectedKey.equals(actualKey); + boolean keyMatches = context.getKeyMatchStrategy().matches(expectedKey, actualKey); boolean valueMatches = AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); 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..43c0d55 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -41,16 +41,24 @@ public MatchResult match(List> records, MatchConte } 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); + if (context.getKeyMatchStrategy().matches(expectedKey, actualKey)) { + log.info( + "Avro record key matches expected key '{}' using {} strategy.", + expectedKey, + context.getKeyMatchStrategy()); return MatchResult.pass(expectedKey, actualKey); } - log.error("Avro record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error( + "Avro record key mismatch, expected: '{}', actual: '{}', strategy: {}", + expectedKey, + actualKey, + context.getKeyMatchStrategy()); return MatchResult.fail( - "Avro record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Avro record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + + "', strategy: " + context.getKeyMatchStrategy() + ".", expectedKey, actualKey); } 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..bcf1fa1 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -50,17 +50,17 @@ 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 keyMatches = context.getKeyMatchStrategy().matches(expectedKey, actualKey); boolean valueMatches = actualValue.equals(expectedValue); if (!keyMatches) { - log.error("Key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); } if (!valueMatches) { log.error( 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..2b03527 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -39,16 +39,24 @@ public MatchResult match(List> records, MatchContext cont } 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); + if (context.getKeyMatchStrategy().matches(expectedKey, actualKey)) { + log.info( + "Record key matches expected key '{}' using {} strategy.", + expectedKey, + context.getKeyMatchStrategy()); return MatchResult.pass(expectedKey, actualKey); } - log.error("Record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error( + "Record key mismatch, expected: '{}', actual: '{}', using {} strategy.", + expectedKey, + actualKey, + context.getKeyMatchStrategy()); return MatchResult.fail( - "Record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + "', using " + + context.getKeyMatchStrategy() + " strategy.", expectedKey, actualKey); } 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..17a6442 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java @@ -34,20 +34,14 @@ 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; /** * 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 +151,7 @@ private ConsumerContext ctx() { // ========================================================================= @Nested - @DisplayName("Consume-only — no matcher") + @DisplayName("Consume-only: no matcher") class ConsumeOnly { @Test @@ -172,7 +166,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 +184,7 @@ void throwsWhenTopicEmpty() { // ========================================================================= @Nested - @DisplayName("File matching — METHOD_MATCH_FILE") + @DisplayName("File matching: METHOD_MATCH_FILE") class FileMatching { @Test @@ -245,7 +239,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter — expectedRecordKey") + @DisplayName("Key filter: expectedRecordKey") class KeyFilter { @Test @@ -273,7 +267,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("XML matching — METHOD_MATCH_XML") + @DisplayName("XML matching: METHOD_MATCH_XML") class XmlMatching { @Test @@ -301,7 +295,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 +344,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 +353,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 +369,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 +386,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 +406,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 +416,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 +461,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 +474,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/KeyMatchStrategyTest.java b/src/test/java/io/github/ktestify/match/KeyMatchStrategyTest.java new file mode 100644 index 0000000..129bfd4 --- /dev/null +++ b/src/test/java/io/github/ktestify/match/KeyMatchStrategyTest.java @@ -0,0 +1,238 @@ +/* + * 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; + +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Unit tests for {@link KeyMatchStrategy}. + * + * @since 1.1.1 + */ +@DisplayName("KeyMatchStrategy") +class KeyMatchStrategyTest { + + @Nested + @DisplayName("EXACT strategy") + class ExactStrategy { + + @ParameterizedTest + @CsvSource({"ORDER-1, ORDER-1", "key-123, key-123", "'', ''"}) + @DisplayName("matches when keys are exactly equal") + void matchesWhenEqual(String expected, String actual) { + assertTrue(KeyMatchStrategy.EXACT.matches(expected, actual)); + } + + @Test + @DisplayName("does not match when keys differ") + void doesNotMatchWhenDifferent() { + assertFalse(KeyMatchStrategy.EXACT.matches("ORDER-1", "ORDER-2")); + } + + @Test + @DisplayName("does not match when actual is null") + void doesNotMatchWhenActualIsNull() { + assertFalse(KeyMatchStrategy.EXACT.matches("ORDER-1", null)); + } + + @Test + @DisplayName("does not match when expected is null") + void doesNotMatchWhenExpectedIsNull() { + assertFalse(KeyMatchStrategy.EXACT.matches(null, "ORDER-1")); + } + } + + @Nested + @DisplayName("CONTAINS strategy") + class ContainsStrategy { + + @ParameterizedTest + @CsvSource({"ORD, ORDER-123", "123, ORDER-123", "ORDER, ORDER-123"}) + @DisplayName("matches when actual contains expected substring") + void matchesWhenContains(String expected, String actual) { + assertTrue(KeyMatchStrategy.CONTAINS.matches(expected, actual)); + } + + @Test + @DisplayName("does not match when actual does not contain expected") + void doesNotMatchWhenNotContains() { + assertFalse(KeyMatchStrategy.CONTAINS.matches("XYZ", "ORDER-123")); + } + + @Test + @DisplayName("does not match when actual is null") + void doesNotMatchWhenActualIsNull() { + assertFalse(KeyMatchStrategy.CONTAINS.matches("ORD", null)); + } + + @Test + @DisplayName("does not match when expected is null") + void doesNotMatchWhenExpectedIsNull() { + assertFalse(KeyMatchStrategy.CONTAINS.matches(null, "ORDER-123")); + } + } + + @Nested + @DisplayName("STARTS_WITH strategy") + class StartsWithStrategy { + + @ParameterizedTest + @CsvSource({"ORD, ORDER-123", "ORDER-, ORDER-123", "ORDER-1, ORDER-123"}) + @DisplayName("matches when actual starts with expected prefix") + void matchesWhenStartsWith(String expected, String actual) { + assertTrue(KeyMatchStrategy.STARTS_WITH.matches(expected, actual)); + } + + @Test + @DisplayName("does not match when actual does not start with expected") + void doesNotMatchWhenNotStartsWith() { + assertFalse(KeyMatchStrategy.STARTS_WITH.matches("USER-", "ORDER-123")); + } + + @Test + @DisplayName("does not match when actual is null") + void doesNotMatchWhenActualIsNull() { + assertFalse(KeyMatchStrategy.STARTS_WITH.matches("ORD", null)); + } + + @Test + @DisplayName("does not match when expected is null") + void doesNotMatchWhenExpectedIsNull() { + assertFalse(KeyMatchStrategy.STARTS_WITH.matches(null, "ORDER-123")); + } + } + + @Nested + @DisplayName("ENDS_WITH strategy") + class EndsWithStrategy { + + @ParameterizedTest + @CsvSource({"123, ORDER-123", "-123, ORDER-123", "R-123, ORDER-123"}) + @DisplayName("matches when actual ends with expected suffix") + void matchesWhenEndsWith(String expected, String actual) { + assertTrue(KeyMatchStrategy.ENDS_WITH.matches(expected, actual)); + } + + @Test + @DisplayName("does not match when actual does not end with expected") + void doesNotMatchWhenNotEndsWith() { + assertFalse(KeyMatchStrategy.ENDS_WITH.matches("456", "ORDER-123")); + } + + @Test + @DisplayName("does not match when actual is null") + void doesNotMatchWhenActualIsNull() { + assertFalse(KeyMatchStrategy.ENDS_WITH.matches("123", null)); + } + + @Test + @DisplayName("does not match when expected is null") + void doesNotMatchWhenExpectedIsNull() { + assertFalse(KeyMatchStrategy.ENDS_WITH.matches(null, "ORDER-123")); + } + } + + @Nested + @DisplayName("REGEX strategy") + class RegexStrategy { + + @ParameterizedTest + @CsvSource({"ORD-\\d+, ORD-123", "ORD-\\d{3}, ORD-123", ".*123, ORDER-123"}) + @DisplayName("matches when actual matches expected regex pattern") + void matchesWhenRegexMatches(String expected, String actual) { + assertTrue(KeyMatchStrategy.REGEX.matches(expected, actual)); + } + + @Test + @DisplayName("does not match when actual does not match regex") + void doesNotMatchWhenRegexDoesNotMatch() { + assertFalse(KeyMatchStrategy.REGEX.matches("ORD-\\d{6}", "ORDER-123")); + } + + @Test + @DisplayName("does not match when actual is null") + void doesNotMatchWhenActualIsNull() { + assertFalse(KeyMatchStrategy.REGEX.matches("ORD-\\d+", null)); + } + + @Test + @DisplayName("does not match when expected is null") + void doesNotMatchWhenExpectedIsNull() { + assertFalse(KeyMatchStrategy.REGEX.matches(null, "ORDER-123")); + } + } + + @Nested + @DisplayName("fromString parsing") + class FromStringParsing { + + @ParameterizedTest + @CsvSource({"exact, EXACT", "EXACT, EXACT", "Exact, EXACT"}) + @DisplayName("parses exact variations") + void parsesExact(String input, KeyMatchStrategy expected) { + assertEquals(expected, KeyMatchStrategy.fromString(input)); + } + + @ParameterizedTest + @CsvSource({"contains, CONTAINS", "CONTAINS, CONTAINS"}) + @DisplayName("parses contains variations") + void parsesContains(String input, KeyMatchStrategy expected) { + assertEquals(expected, KeyMatchStrategy.fromString(input)); + } + + @ParameterizedTest + @CsvSource({ + "starts_with, STARTS_WITH", + "starts-with, STARTS_WITH", + "STARTS-WITH, STARTS_WITH", + "STARTS WITH, STARTS_WITH" + }) + @DisplayName("parses starts_with variations including hyphens and spaces") + void parsesStartsWith(String input, KeyMatchStrategy expected) { + assertEquals(expected, KeyMatchStrategy.fromString(input)); + } + + @ParameterizedTest + @CsvSource({"ends_with, ENDS_WITH", "ends-with, ENDS_WITH", "ENDS-WITH, ENDS_WITH", "ENDS WITH, ENDS_WITH"}) + @DisplayName("parses ends_with variations including hyphens and spaces") + void parsesEndsWith(String input, KeyMatchStrategy expected) { + assertEquals(expected, KeyMatchStrategy.fromString(input)); + } + + @ParameterizedTest + @CsvSource({"regex, REGEX", "REGEX, REGEX"}) + @DisplayName("parses regex variations") + void parsesRegex(String input, KeyMatchStrategy expected) { + assertEquals(expected, KeyMatchStrategy.fromString(input)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "unknown", "invalid-strategy"}) + @DisplayName("defaults to EXACT for null, blank, or unrecognized values") + void defaultsToExactForUnrecognized(String input) { + assertEquals(KeyMatchStrategy.EXACT, KeyMatchStrategy.fromString(input)); + } + } +} diff --git a/src/test/java/io/github/ktestify/match/impl/KeyRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/KeyRecordMatcherTest.java index 34c6800..f79e517 100644 --- a/src/test/java/io/github/ktestify/match/impl/KeyRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/KeyRecordMatcherTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.*; import io.github.ktestify.exceptions.ComparisonException; +import io.github.ktestify.match.KeyMatchStrategy; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; import org.junit.jupiter.api.DisplayName; @@ -87,4 +88,87 @@ void resultCarriesKeys() throws ComparisonException { assertEquals("ACTUAL", result.getActual()); } } + + @Nested + @DisplayName("Key match strategies") + class KeyStrategies { + + @Test + @DisplayName("STARTS_WITH passes when key starts with expected prefix") + void startsWithPasses() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("ORD-abc-123", "value"), ctxWithKeyAndStrategy("ORD-", KeyMatchStrategy.STARTS_WITH)); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("STARTS_WITH fails when key does not start with expected prefix") + void startsWithFails() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("USER-abc-123", "value"), ctxWithKeyAndStrategy("ORD-", KeyMatchStrategy.STARTS_WITH)); + assertFalse(result.isPassed()); + } + + @Test + @DisplayName("CONTAINS passes when key contains expected substring") + void containsPasses() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("order-ABC-123", "value"), ctxWithKeyAndStrategy("ABC", KeyMatchStrategy.CONTAINS)); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("CONTAINS fails when key does not contain expected substring") + void containsFails() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("order-XYZ-123", "value"), ctxWithKeyAndStrategy("ABC", KeyMatchStrategy.CONTAINS)); + assertFalse(result.isPassed()); + } + + @Test + @DisplayName("ENDS_WITH passes when key ends with expected suffix") + void endsWithPasses() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("ORD-abc-123", "value"), ctxWithKeyAndStrategy("-123", KeyMatchStrategy.ENDS_WITH)); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("ENDS_WITH fails when key does not end with expected suffix") + void endsWithFails() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("ORD-abc-456", "value"), ctxWithKeyAndStrategy("-123", KeyMatchStrategy.ENDS_WITH)); + assertFalse(result.isPassed()); + } + + @Test + @DisplayName("REGEX passes when key matches expected pattern") + void regexPasses() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("ORD-123456", "value"), ctxWithKeyAndStrategy("ORD-\\d{6}", KeyMatchStrategy.REGEX)); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("REGEX fails when key does not match expected pattern") + void regexFails() throws ComparisonException { + MatchResult result = matcher.match( + rawRecord("ORD-abc", "value"), ctxWithKeyAndStrategy("ORD-\\d{6}", KeyMatchStrategy.REGEX)); + assertFalse(result.isPassed()); + } + + @Test + @DisplayName("EXACT is the default when strategy is not set") + void exactIsDefault() throws ComparisonException { + MatchResult result = matcher.match(rawRecord("ORD-123", "value"), ctxWithKey("ORD-123")); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("EXACT fails for partial match (backward compatibility)") + void exactFailsForPartial() throws ComparisonException { + MatchResult result = matcher.match(rawRecord("ORD-123", "value"), ctxWithKey("ORD")); + assertFalse(result.isPassed()); + } + } } 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..cacb387 100644 --- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java +++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java @@ -15,6 +15,7 @@ */ package io.github.ktestify.match.impl; +import io.github.ktestify.match.KeyMatchStrategy; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; @@ -107,6 +108,14 @@ static MatchContext ctxWithKey(String matchKey) { return MatchContext.builder().matchKey(matchKey).build(); } + /** Builds a {@link MatchContext} with a match key and a key match strategy. */ + static MatchContext ctxWithKeyAndStrategy(String matchKey, KeyMatchStrategy strategy) { + return MatchContext.builder() + .matchKey(matchKey) + .keyMatchStrategy(strategy) + .build(); + } + /** Builds a {@link MatchContext} with a match key and an inline expected value. */ static MatchContext ctxWithKeyAndValue(String matchKey, String matchValue) { return MatchContext.builder().matchKey(matchKey).matchValue(matchValue).build();