diff --git a/src/main/java/io/github/ktestify/match/MatchContext.java b/src/main/java/io/github/ktestify/match/MatchContext.java index 58dba8b..70710aa 100644 --- a/src/main/java/io/github/ktestify/match/MatchContext.java +++ b/src/main/java/io/github/ktestify/match/MatchContext.java @@ -73,6 +73,20 @@ public class MatchContext { /** Expected value for {@link #matchKey}. */ String matchValue; + /** + * Multiple key/value pairs for multi-field inline matching. + * + *

When non-empty, the matcher validates that every key in the map has the corresponding expected value + * in the actual record. This is the multi-field equivalent of {@link #matchKey} / {@link #matchValue}. + * + *

Single-field callers should continue to use {@link #matchKey} and {@link #matchValue}; this map should be left + * empty (the default). + * + * @since 1.1.1 + */ + @Builder.Default + Map matchKeyValues = Collections.emptyMap(); + /** * Expected transport-attribute key/value pairs (see * {@link io.github.ktestify.models.ConsumedRecord#getAttributes()}). @@ -90,7 +104,7 @@ public class MatchContext { * {@code null} if the list is empty. */ public String getMatchFilePath() { - return matchFilePaths != null && !matchFilePaths.isEmpty() ? matchFilePaths.get(0) : null; + return matchFilePaths != null && !matchFilePaths.isEmpty() ? matchFilePaths.getFirst() : null; } /** 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 f6220ed..d19e3d6 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java @@ -23,7 +23,9 @@ import io.github.ktestify.utils.FileUtils; import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; @@ -44,11 +46,16 @@ public class AvroFieldsRecordMatcher implements RecordMatcher { public MatchResult match(List> records, MatchContext context) throws ComparisonException { + // Multi-field inline matching (keys/values columns) + if (context.getMatchKeyValues() != null && !context.getMatchKeyValues().isEmpty()) { + return matchMultipleFields(records, context); + } + if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroFieldsRecordMatcher requires matchKey (the field name) to be set."); } - GenericRecord value = records.get(0).getValue(); + GenericRecord value = records.getFirst().getValue(); String actualValue = toJson(value); String key = context.getMatchKey(); @@ -83,6 +90,40 @@ public MatchResult match(List> records, MatchConte throw new ComparisonException("AvroFieldsRecordMatcher requires either matchValue or matchFilePath to be set."); } + /** + * 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. + * + * @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) + * @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(); + + log.debug("Avro multi-field match against inline values : pairs: {}", keyValues); + + List mismatches = new ArrayList<>(); + for (Map.Entry entry : keyValues.entrySet()) { + String key = entry.getKey(); + String expectedVal = entry.getValue(); + if (!AvroUtils.doesAvroValueFromKeyMatchesRecord(expectedVal, key, actualValue)) { + mismatches.add("Avro field '" + key + "' does not match expected value '" + expectedVal + "'."); + } + } + + if (mismatches.isEmpty()) { + return MatchResult.pass(keyValues.toString(), actualValue); + } + return MatchResult.fail(String.join(" ", mismatches), keyValues.toString(), actualValue); + } + private String toJson(GenericRecord value) { if (value.getSchema() != null) { return AvroUtils.getPrettyAvroValue( diff --git a/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java index 38a0dfd..97d622c 100644 --- a/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java @@ -21,6 +21,8 @@ import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.match.MatchContext; import io.github.ktestify.match.MatchResult; +import java.util.LinkedHashMap; +import java.util.Map; import org.apache.avro.Schema; import org.apache.avro.SchemaBuilder; import org.apache.avro.generic.GenericData; @@ -77,6 +79,62 @@ void fieldDoesNotMatchInlineValue() throws ComparisonException { } } + @Nested + @DisplayName("Multi-field inline value matching") + class MultiFieldValue { + + @Test + @DisplayName("passes when all specified fields match their inline expected values") + void allFieldsMatchInlineValues() throws ComparisonException { + Map keyValues = new LinkedHashMap<>(); + keyValues.put("status", "CREATED"); + keyValues.put("customerId", "CUST-42"); + MatchResult result = matcher.match( + avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "CREATED")), + ctxWithKeyValues(keyValues)); + assertTrue(result.isPassed()); + } + + @Test + @DisplayName("fails when one of the specified fields does not match") + void oneFieldDoesNotMatch() throws ComparisonException { + Map keyValues = new LinkedHashMap<>(); + keyValues.put("status", "CREATED"); + keyValues.put("customerId", "CUST-99"); + MatchResult result = matcher.match( + avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "SHIPPED")), + ctxWithKeyValues(keyValues)); + assertFalse(result.isPassed()); + assertTrue(result.getDiff().contains("status")); + assertTrue(result.getDiff().contains("customerId")); + } + + @Test + @DisplayName("fails when all specified fields do not match") + void noFieldsMatch() throws ComparisonException { + Map keyValues = new LinkedHashMap<>(); + keyValues.put("status", "CREATED"); + keyValues.put("orderId", "ORD-999"); + MatchResult result = matcher.match( + avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "SHIPPED")), + ctxWithKeyValues(keyValues)); + assertFalse(result.isPassed()); + assertTrue(result.getDiff().contains("status")); + assertTrue(result.getDiff().contains("orderId")); + } + + @Test + @DisplayName("passes with a single entry in the key/value map") + void singleEntryKeyValues() throws ComparisonException { + Map keyValues = new LinkedHashMap<>(); + keyValues.put("status", "CREATED"); + MatchResult result = matcher.match( + avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "CREATED")), + ctxWithKeyValues(keyValues)); + assertTrue(result.isPassed()); + } + } + @Nested @DisplayName("File-based matching") class FileBased { 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 16036a5..4ed40c3 100644 --- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java +++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java @@ -111,4 +111,9 @@ static MatchContext ctxWithKey(String matchKey) { static MatchContext ctxWithKeyAndValue(String matchKey, String matchValue) { return MatchContext.builder().matchKey(matchKey).matchValue(matchValue).build(); } + + /** Builds a {@link MatchContext} with multiple key/value pairs for multi-field inline matching. */ + static MatchContext ctxWithKeyValues(Map keyValues) { + return MatchContext.builder().matchKeyValues(keyValues).build(); + } }