Skip to content

Commit 3cb7fcb

Browse files
committed
feat: When field has format but not pattern make sure fuzzed value is matched against format
1 parent ddac2a1 commit 3cb7fcb

8 files changed

Lines changed: 132 additions & 188 deletions

File tree

src/main/java/dev/dochia/cli/core/playbook/field/DuplicateKeysFieldsPlaybook.java

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import com.google.gson.JsonParser;
66
import dev.dochia.cli.core.http.HttpMethod;
77
import dev.dochia.cli.core.http.ResponseCodeFamilyPredefined;
8-
import dev.dochia.cli.core.io.ServiceData;
98
import dev.dochia.cli.core.model.PlaybookData;
109
import dev.dochia.cli.core.playbook.api.FieldPlaybook;
1110
import dev.dochia.cli.core.playbook.api.TestCasePlaybook;
@@ -119,21 +118,6 @@ private void runDuplicationTestCase(PlaybookData data, String field, String dupl
119118
.build());
120119
}
121120

122-
private ServiceData buildServiceData(PlaybookData data, String payload) {
123-
return ServiceData.builder()
124-
.relativePath(data.getPath())
125-
.headers(data.getHeaders())
126-
.payload(payload)
127-
.queryParams(data.getQueryParams())
128-
.httpMethod(data.getMethod())
129-
.contractPath(data.getContractPath())
130-
.replaceRefData(false)
131-
.contentType(data.getFirstRequestContentType())
132-
.pathParamsPayload(data.getPathParamsPayload())
133-
.validJson(false)
134-
.build();
135-
}
136-
137121
/**
138122
* Recursively renders JSON while duplicating the target key when the path matches.
139123
*/

src/main/java/dev/dochia/cli/core/playbook/field/base/BaseFieldsPlaybook.java

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
import io.github.ludovicianul.prettylogger.PrettyLoggerFactory;
1919
import io.swagger.v3.oas.models.media.Schema;
2020

21+
import java.time.LocalDate;
22+
import java.time.OffsetDateTime;
23+
import java.time.format.DateTimeFormatter;
24+
import java.time.format.DateTimeParseException;
2125
import java.util.List;
2226
import java.util.Map;
2327
import java.util.Set;
28+
import java.util.UUID;
2429
import java.util.regex.Pattern;
2530

2631
/**
@@ -241,16 +246,60 @@ private boolean isFuzzedValueMatchingPattern(
241246
Object fieldValue, PlaybookData data, String fuzzedField) {
242247
if (this.shouldCheckForFuzzedValueMatchingPattern()) {
243248
Schema<?> fieldSchema = data.getRequestPropertyTypes().get(fuzzedField);
244-
if (fieldSchema.getPattern() == null || DochiaModelUtils.isByteArraySchema(fieldSchema)) {
249+
if (DochiaModelUtils.isByteArraySchema(fieldSchema)) {
245250
return true;
246251
}
247-
Pattern pattern = Pattern.compile(fieldSchema.getPattern());
248252

249-
return fieldValue == null || pattern.matcher(this.sanitizeString(fieldValue)).matches();
253+
if (fieldSchema.getPattern() != null) {
254+
Pattern pattern = Pattern.compile(fieldSchema.getPattern());
255+
return fieldValue == null || pattern.matcher(this.sanitizeString(fieldValue)).matches();
256+
}
257+
258+
return fieldValue == null || isValueMatchingFormat(fieldSchema, this.sanitizeString(fieldValue));
250259
}
251260
return true;
252261
}
253262

263+
private boolean isValueMatchingFormat(Schema<?> fieldSchema, String sanitizedValue) {
264+
String format = fieldSchema.getFormat();
265+
if (format == null) {
266+
return true;
267+
}
268+
return switch (format) {
269+
case "date" -> isValidDate(sanitizedValue);
270+
case "date-time" -> isValidDateTime(sanitizedValue);
271+
case "uuid" -> isValidUuid(sanitizedValue);
272+
default -> true;
273+
};
274+
}
275+
276+
private static boolean isValidDate(String value) {
277+
try {
278+
LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE);
279+
return true;
280+
} catch (DateTimeParseException e) {
281+
return false;
282+
}
283+
}
284+
285+
private static boolean isValidDateTime(String value) {
286+
try {
287+
OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
288+
return true;
289+
} catch (DateTimeParseException e) {
290+
return false;
291+
}
292+
}
293+
294+
private static boolean isValidUuid(String value) {
295+
try {
296+
UUID.fromString(value);
297+
return true;
298+
} catch (IllegalArgumentException e) {
299+
return false;
300+
}
301+
}
302+
254303
/**
255304
* We need to sanitize the fuzzed value before matching it to the pattern as APIs are expected to
256305
* also sanitize data before validating it.

src/main/java/dev/dochia/cli/core/report/ExecutionStatisticsListener.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import dev.dochia.cli.core.util.AnsiUtils;
55
import jakarta.enterprise.context.ApplicationScoped;
66
import lombok.Getter;
7-
import org.fusesource.jansi.Ansi;
87

98
import java.util.HashMap;
109
import java.util.Map;

src/main/java/dev/dochia/cli/core/util/DochiaModelUtils.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,14 @@ public static boolean hasLengthThree(Schema<?> schema) {
309309
(schema.getMaxLength() != null && schema.getMaxLength() == 3);
310310
}
311311

312+
public static boolean hasOneOf(Schema schema) {
313+
return ModelUtils.hasOneOf(schema);
314+
}
315+
316+
public static boolean hasAnyOf(Schema schema) {
317+
return ModelUtils.hasAnyOf(schema);
318+
}
319+
312320
public static boolean isAnyOf(Schema schema) {
313321
return ModelUtils.isAnyOf(schema);
314322
}

src/main/java/dev/dochia/cli/core/util/WordUtils.java

Lines changed: 36 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import org.apache.commons.lang3.StringUtils;
44
import org.apache.commons.text.similarity.JaccardSimilarity;
5-
import org.apache.commons.text.similarity.LevenshteinDistance;
65

76
import java.util.Arrays;
87
import java.util.List;
@@ -12,7 +11,6 @@
1211
import java.util.TreeSet;
1312
import java.util.concurrent.ConcurrentHashMap;
1413
import java.util.function.UnaryOperator;
15-
import java.util.regex.Matcher;
1614
import java.util.regex.Pattern;
1715
import java.util.stream.Collectors;
1816

@@ -345,136 +343,49 @@ public static List<String> getKeywordsMatching(String response, Set<String> prov
345343
}
346344

347345
/**
348-
* Normalize an error message to a structural form:
349-
* - collapse \"...\" content to \"\"
350-
* - replace variable-like tokens with placeholders
351-
* - squash whitespace/noise
346+
* Detects the casing of a string based on its format.
352347
*
353-
* @param s the error message to normalize
354-
* @return the error message normalized
348+
* @param sample The string to detect the casing of.
349+
* @return The detected casing as a string.
355350
*/
356-
public static String normalizeErrorMessage(String s) {
357-
if (StringUtils.isBlank(s)) {
358-
return "";
351+
public static String detectCasingFromString(String sample) {
352+
if (sample.contains("_") && sample.equals(sample.toUpperCase(Locale.ROOT))) {
353+
return "UPPER_SNAKE_CASE";
354+
} else if (sample.contains("_") && sample.equals(sample.toLowerCase(Locale.ROOT))) {
355+
return "lower_snake_case";
356+
} else if (sample.contains("-")) {
357+
return "kebab-case";
358+
} else if (Character.isLowerCase(sample.charAt(0)) && sample.matches(".*[A-Z].*")) {
359+
return "camelCase";
360+
} else if (Character.isUpperCase(sample.charAt(0)) && sample.matches(".*[a-z].*")) {
361+
return "PascalCase";
362+
} else if (sample.equals(sample.toLowerCase(Locale.ROOT))) {
363+
return "lowercase";
359364
}
360-
361-
String r = s;
362-
363-
// 1) Collapse escaped, inner quoted segments so only the quotes remain.
364-
r = collapseEscapedQuotedSegments(r);
365-
366-
// 2) Replace common highly-variable substrings with placeholders.
367-
r = TS.matcher(r).replaceAll("TIMESTAMP");
368-
r = UUID.matcher(r).replaceAll("UUID");
369-
r = HASH.matcher(r).replaceAll("HASH");
370-
r = URL.matcher(r).replaceAll("URL");
371-
r = PATH.matcher(r).replaceAll("PATH");
372-
r = DIGITS.matcher(r).replaceAll("NUM");
373-
r = BASE64ISH.matcher(r).replaceAll("TOKEN");
374-
375-
// 3) Replace uppercase ID-like tokens (generic, handles DYX/XIIR... cases).
376-
r = replaceUpperTokens(r);
377-
378-
// 4) Remove zero-width and spacing noise, normalize whitespace.
379-
r = ZCMS.matcher(r).replaceAll(" ");
380-
r = MULTI_SPACE.matcher(r).replaceAll(" ").trim();
381-
382-
return r;
383-
}
384-
385-
// Helper: replace UPPER_TOKEN occurrences with TOKEN unless whitelisted
386-
private static String replaceUpperTokens(String s) {
387-
StringBuilder out = new StringBuilder(s.length());
388-
Matcher m = UPPER_TOKEN.matcher(s);
389-
while (m.find()) {
390-
String tok = m.group();
391-
if (UPPER_WHITELIST.contains(tok)) {
392-
m.appendReplacement(out, tok);
393-
} else {
394-
m.appendReplacement(out, "TOKEN");
395-
}
396-
}
397-
m.appendTail(out);
398-
return out.toString();
399-
}
400-
401-
// Helper: collapse occurrences of \" ... \" to \"\"
402-
// (works well for messages like: ... parsing \"👩🏾false\" : invalid syntax)
403-
private static String collapseEscapedQuotedSegments(String s) {
404-
int i = 0;
405-
int n = s.length();
406-
StringBuilder sb = new StringBuilder(n);
407-
while (i < n) {
408-
int open = s.indexOf("\\\"", i);
409-
if (open < 0) {
410-
sb.append(s, i, n);
411-
break;
412-
}
413-
// copy up to the start of the escaped quote
414-
sb.append(s, i, open);
415-
// write collapsed pair \"\"
416-
sb.append("\\\"\\\"");
417-
// find the next closing escaped quote
418-
int j = open + 2;
419-
int close = s.indexOf("\\\"", j);
420-
if (close < 0) {
421-
// no closing pair; append rest and finish
422-
sb.append(s, j, n);
423-
break;
424-
}
425-
// skip the content and the closing pair
426-
i = close + 2;
427-
}
428-
return sb.toString();
365+
return "UPPER_SNAKE_CASE"; // default
429366
}
430367

431368
/**
432-
* Main similarity predicate (stable and fast).
433-
* <p>
434-
* - Cheap Jaccard gate on normalized strings.
435-
* - Thresholded Levenshtein (banded) based on what remains necessary.
369+
* Coverts a string to the detected casing convention.
436370
*
437-
* @param a the first error message
438-
* @param b the second error message
439-
* @return true if the error messages are similar, false otherwise
371+
* @param name the string to convert
372+
* @param casingConvention the casing to convert to
373+
* @return the converted string
440374
*/
441-
public static boolean areErrorsSimilar(String a, String b) {
442-
if (StringUtils.isBlank(a) || StringUtils.isBlank(b)) {
443-
return false;
444-
}
445-
if (a.equals(b)) {
446-
return true;
447-
}
448-
449-
// Normalize once with caching
450-
final String na = NORMALIZED_CACHE.computeIfAbsent(a, WordUtils::normalizeErrorMessage);
451-
final String nb = NORMALIZED_CACHE.computeIfAbsent(b, WordUtils::normalizeErrorMessage);
452-
453-
// Fast structural equality
454-
if (na.equals(nb)) {
455-
return true;
456-
}
457-
458-
// Cheap token similarity gate
459-
final double token = JS.apply(na, nb);
460-
if (token < JACCARD_THRESHOLD) {
461-
return false;
462-
}
463-
464-
// Compute minimal LD similarity still needed to reach combined threshold.
465-
// combined = (ldSim + token) / 2 >= COMBINED_THRESHOLD
466-
final double minLdSim = Math.max(0.0, 2 * COMBINED_THRESHOLD - token);
467-
468-
// Convert to an edit-distance bound over the normalized strings:
469-
final int maxLen = Math.max(na.length(), nb.length());
470-
final int maxEdits = (int) Math.ceil(maxLen * (1.0 - minLdSim));
471-
472-
final Integer dist = new LevenshteinDistance(maxEdits).apply(na, nb);
473-
if (dist < 0) {
474-
return false; // exceeded bound
475-
}
476-
final double ldSim = 1.0 - (dist.doubleValue() / maxLen);
477-
478-
return (ldSim + token) / 2.0 >= COMBINED_THRESHOLD;
375+
public static String convertToDetectedCasing(String name, String casingConvention) {
376+
return switch (casingConvention) {
377+
case "lower_snake_case" -> name.replaceAll("([a-z])([A-Z])", "$1_$2")
378+
.replaceAll("([A-Z])([A-Z][a-z])", "$1_$2")
379+
.toLowerCase(Locale.ROOT);
380+
case "kebab-case" -> name.replaceAll("([a-z])([A-Z])", "$1-$2")
381+
.replaceAll("([A-Z])([A-Z][a-z])", "$1-$2")
382+
.toLowerCase(Locale.ROOT);
383+
case "camelCase" -> Character.toLowerCase(name.charAt(0)) + name.substring(1);
384+
case "PascalCase" -> name;
385+
case "lowercase" -> name.toLowerCase(Locale.ROOT);
386+
default -> name.replaceAll("([a-z])([A-Z])", "$1_$2")
387+
.replaceAll("([A-Z])([A-Z][a-z])", "$1_$2")
388+
.toUpperCase(Locale.ROOT);
389+
};
479390
}
480391
}

src/test/java/dev/dochia/cli/core/playbook/field/DuplicateKeysFieldsPlaybookTest.java

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
package dev.dochia.cli.core.playbook.field;
22

3-
import dev.dochia.cli.core.args.FilterArguments;
43
import dev.dochia.cli.core.http.HttpMethod;
5-
import dev.dochia.cli.core.http.ResponseCodeFamilyPredefined;
6-
import dev.dochia.cli.core.io.ServiceCaller;
7-
import dev.dochia.cli.core.model.HttpResponse;
84
import dev.dochia.cli.core.model.PlaybookData;
95
import dev.dochia.cli.core.playbook.executor.SimpleExecutor;
10-
import dev.dochia.cli.core.report.TestCaseListener;
11-
import dev.dochia.cli.core.report.TestReportsGenerator;
12-
import io.github.ludovicianul.prettylogger.PrettyLogger;
136
import io.quarkus.test.junit.QuarkusTest;
14-
import io.quarkus.test.junit.mockito.InjectSpy;
157
import org.junit.jupiter.api.BeforeEach;
168
import org.junit.jupiter.api.Test;
179
import org.junit.jupiter.params.ParameterizedTest;
1810
import org.junit.jupiter.params.provider.CsvSource;
1911
import org.mockito.Mockito;
20-
import org.springframework.test.util.ReflectionTestUtils;
2112

2213
import java.util.Collections;
2314
import java.util.HashMap;

0 commit comments

Comments
 (0)