Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/main/java/io/github/ktestify/match/MatchContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ public class MatchContext {
/** Expected value for {@link #matchKey}. */
String matchValue;

/**
* Multiple key/value pairs for multi-field inline matching.
*
* <p>When non-empty, the matcher validates that <em>every</em> key in the map has the corresponding expected value
* in the actual record. This is the multi-field equivalent of {@link #matchKey} / {@link #matchValue}.
*
* <p>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<String, String> matchKeyValues = Collections.emptyMap();

/**
* Expected transport-attribute key/value pairs (see
* {@link io.github.ktestify.models.ConsumedRecord#getAttributes()}).
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -44,11 +46,16 @@ public class AvroFieldsRecordMatcher implements RecordMatcher<GenericRecord> {
public MatchResult match(List<ConsumedRecord<GenericRecord>> 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();

Expand Down Expand Up @@ -83,6 +90,40 @@ public MatchResult match(List<ConsumedRecord<GenericRecord>> 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.
*
* <p>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<ConsumedRecord<GenericRecord>> records, MatchContext context)
throws ComparisonException {
GenericRecord value = records.getFirst().getValue();
String actualValue = toJson(value);
Map<String, String> keyValues = context.getMatchKeyValues();

log.debug("Avro multi-field match against inline values : pairs: {}", keyValues);

List<String> mismatches = new ArrayList<>();
for (Map.Entry<String, String> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> keyValues) {
return MatchContext.builder().matchKeyValues(keyValues).build();
}
}
Loading