diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 96814a8..104b936 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -14,6 +14,7 @@ name: Changelog permissions: contents: read + pull-requests: read on: push: 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 1966b1c..9104153 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 1955072..83f1955 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -376,7 +376,7 @@ private void release(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 @@ -390,7 +390,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 a4044af..05e87c1 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -65,7 +65,7 @@ public MatchResult match(List> records, MatchConte GenericRecord value = record.getValue(); String actualValue = value == null ? null : AvroJson.of(value); - boolean keyMatches = expectedKey.equals(actualKey); + boolean keyMatches = context.getKeyMatchStrategy().matches(expectedKey, actualKey); boolean valueMatches = actualValue != null && valueMatches(expectedValue, actualValue, context); if (!keyMatches) { 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 ccae0ff..941bd18 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -48,14 +48,22 @@ public MatchResult match(List> records, MatchConte String expectedKey = context.getMatchKey(); 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 158a491..e42891a 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -60,7 +60,7 @@ public MatchResult match(List> records, MatchContext cont String expectedKey = context.getMatchKey(); String actualKey = record.getKey(); - boolean keyMatches = expectedKey.equals(actualKey); + boolean keyMatches = context.getKeyMatchStrategy().matches(expectedKey, actualKey); boolean valueMatches = expectedValue.equals(actualValue); if (!keyMatches) { 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 de835af..ee2d13c 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -46,14 +46,22 @@ public MatchResult match(List> records, MatchContext cont String expectedKey = context.getMatchKey(); 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/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 152bc42..1d92828 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();