View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.juneau;
18  
19  import static java.util.stream.Collectors.*;
20  import static org.apache.juneau.commons.utils.CollectionUtils.*;
21  import static org.apache.juneau.commons.utils.ThrowableUtils.*;
22  import static org.junit.jupiter.api.Assertions.*;
23  
24  import java.io.*;
25  import java.lang.reflect.*;
26  import java.net.*;
27  import java.util.*;
28  import java.util.regex.*;
29  import java.util.stream.*;
30  
31  import org.apache.juneau.annotation.*;
32  import org.apache.juneau.bean.swagger.*;
33  import org.apache.juneau.commons.utils.*;
34  import org.apache.juneau.junit.bct.*;
35  import org.apache.juneau.marshaller.*;
36  import org.apache.juneau.rest.*;
37  import org.apache.juneau.rest.mock.*;
38  import org.apache.juneau.serializer.*;
39  import org.apache.juneau.xml.*;
40  import org.junit.jupiter.api.*;
41  
42  /**
43   * Comprehensive utility class for Bean-Centric Tests (BCT) and general testing operations.
44   *
45   * <p>This class provides the core testing infrastructure for Apache Juneau, with particular emphasis
46   * on the Bean-Centric Testing (BCT) framework. BCT enables sophisticated assertion patterns for
47   * testing object properties, collections, maps, and complex nested structures with minimal code.</p>
48   *
49   * <h5 class='section'>Bean-Centric Testing (BCT) Framework:</h5>
50   * <p>The BCT framework consists of several key components:</p>
51   * <ul>
52   * 	<li><b>{@link BeanConverter}:</b> Core interface for object conversion and property access</li>
53   * 	<li><b>{@link BasicBeanConverter}:</b> Default implementation with extensible type handlers</li>
54   * 	<li><b>Assertion Methods:</b> High-level testing methods that leverage the converter framework</li>
55   * </ul>
56   *
57   * <h5 class='section'>Primary BCT Assertion Methods:</h5>
58   * <dl>
59   * 	<dt><b>{@link #assertBean(Object, String, String)}</b></dt>
60   * 	<dd>Tests object properties with nested syntax support and collection iteration</dd>
61   *
62   * 	<dt><b>{@link #assertMap(Map, String, String)}</b></dt>
63   * 	<dd>Tests map entries with the same nested property syntax as assertBean</dd>
64   *
65   * 	<dt><b>{@link #assertMapped(Object, java.util.function.BiFunction, String, String)}</b></dt>
66   * 	<dd>Tests custom property access using BiFunction for non-standard objects</dd>
67   *
68   * 	<dt><b>{@link #assertList(List, Object...)}</b></dt>
69   * 	<dd>Tests list/collection elements with varargs for expected values</dd>
70   *
71   * 	<dt><b>{@link #assertBeans(Collection, String, String...)}</b></dt>
72   * 	<dd>Tests collections of objects by extracting and comparing specific fields</dd>
73   * </dl>
74   *
75   * <h5 class='section'>BCT Advanced Features:</h5>
76   * <ul>
77   * 	<li><b>Nested Property Syntax:</b> "address{street,city}" for testing nested objects</li>
78   * 	<li><b>Collection Iteration:</b> "#{property}" syntax for testing all elements</li>
79   * 	<li><b>Universal Size Properties:</b> "length" and "size" work on all collection types</li>
80   * 	<li><b>Array/List Access:</b> Numeric indices for element-specific testing</li>
81   * 	<li><b>Method Chaining:</b> Fluent setters can be tested directly</li>
82   * 	<li><b>Direct Field Access:</b> Public fields accessed without getters</li>
83   * 	<li><b>Map Key Access:</b> Including special "&lt;NULL&gt;" syntax for null keys</li>
84   * </ul>
85   *
86   * <h5 class='section'>Converter Extensibility:</h5>
87   * <p>The BCT framework is built on the extensible {@link BasicBeanConverter} which allows:</p>
88   * <ul>
89   * 	<li><b>Custom Stringifiers:</b> Type-specific string conversion logic</li>
90   * 	<li><b>Custom Listifiers:</b> Collection-type conversion for iteration</li>
91   * 	<li><b>Custom Swapifiers:</b> Object transformation before conversion</li>
92   * 	<li><b>Configurable Settings:</b> Formatting, delimiters, and display options</li>
93   * </ul>
94   *
95   * <h5 class='section'>Usage Examples:</h5>
96   *
97   * <p><b>Basic Property Testing:</b></p>
98   * <p class='bjava'>
99   * 	<jc>// Test multiple properties</jc>
100  * 	assertBean(user, <js>"name,age,active"</js>, <js>"John,30,true"</js>);
101  *
102  * 	<jc>// Test nested properties</jc>
103  * 	assertBean(user, <js>"address{street,city}"</js>, <js>"{123 Main St,Springfield}"</js>);
104  * </p>
105  *
106  * <p><b>Collection and Array Testing:</b></p>
107  * <p class='bjava'>
108  * 	<jc>// Test collection size and iterate over all elements</jc>
109  * 	assertBean(order, <js>"items{length,#{name}}"</js>, <js>"{3,[{Laptop},{Phone},{Tablet}]}"</js>);
110  *
111  * 	<jc>// Test specific array elements</jc>
112  * 	assertBean(data, <js>"values{0,1,2}"</js>, <js>"{100,200,300}"</js>);
113  * </p>
114  *
115  * <p><b>Map and Collection Testing:</b></p>
116  * <p class='bjava'>
117  * 	<jc>// Test map entries</jc>
118  * 	assertMap(config, <js>"timeout,retries"</js>, <js>"30000,3"</js>);
119  *
120  * 	<jc>// Test list elements</jc>
121  * 	assertList(tags, <js>"red"</js>, <js>"green"</js>, <js>"blue"</js>);
122  * </p>
123  *
124  * <p><b>Custom Property Access:</b></p>
125  * <p class='bjava'>
126  * 	<jc>// Test with custom accessor function</jc>
127  * 	assertMapped(myObject, (obj, prop) -> obj.getProperty(prop),
128  * 		<js>"prop1,prop2"</js>, <js>"value1,value2"</js>);
129  * </p>
130  *
131  * <h5 class='section'>Performance and Thread Safety:</h5>
132  * <p>The BCT framework is designed for high performance with:</p>
133  * <ul>
134  * 	<li><b>Caching:</b> Type-to-handler mappings cached for fast lookup</li>
135  * 	<li><b>Thread Safety:</b> All operations are thread-safe for concurrent testing</li>
136  * 	<li><b>Minimal Allocation:</b> Efficient object reuse and minimal temporary objects</li>
137  * </ul>
138  *
139  * @see BeanConverter
140  * @see BasicBeanConverter
141  */
142 public class TestUtils extends Utils {
143 
144 	private static final ThreadLocal<TimeZone> SYSTEM_TIME_ZONE = new ThreadLocal<>();
145 
146 	public static final ThreadLocal<Locale> SYSTEM_LOCALE = new ThreadLocal<>();
147 
148 
149 	public static void assertEqualsAll(Object...values) {
150 		for (var i = 1; i < values.length; i++) {
151 			assertEquals(values[0], values[i], fs("Elements at index {0} and {1} did not match. {0}={2}, {1}={3}", 0, i, r(values[0]), r(values[i])));
152 		}
153 	}
154 
155 	/**
156 	 * Asserts the JSON5 representation of the specified object.
157 	 */
158 	public static void assertJson(String expected, Object value) {
159 		assertEquals(expected, Json5.DEFAULT_SORTED.write(value));
160 	}
161 
162 	/**
163 	 * Converts the specified object to a string and then replaces any newlines with pipes for easy comparison during testing.
164 	 * @param value
165 	 */
166 	public static String pipedLines(Object value) {
167 		return r(value).replaceAll("\\r?\\n", "|");
168 	}
169 
170 
171 	public static void assertNotEqualsAny(Object actual, Object...values) {
172 		assertNotNull(actual, "Value was null.");
173 		for (var i = 0; i < values.length; i++) {
174 			assertNotEquals(values[i], actual, fs("Element at index {0} unexpectedly matched.  expected={1}, actual={2}", i, values[i], s(actual)));
175 		}
176 	}
177 
178 	/**
179 	 * Asserts the serialized representation of the specified object.
180 	 */
181 	public static void assertSerialized(Object actual, WriterSerializer s, String expected) {
182 		assertEquals(expected, s.toString(actual));
183 	}
184 
185 	public static <T extends Throwable> T assertThrowable(Class<? extends Throwable> expectedType, String expectedSubstring, T t) {
186 		var messages = getMessages(t);
187 		assertTrue(messages.contains(expectedSubstring), fs("Expected message to contain: {0}.\nActual:\n{1}", expectedSubstring, messages));
188 		return t;
189 	}
190 
191 	public static <T extends Throwable> T assertThrowsWithMessage(Class<T> expectedType, List<String> expectedSubstrings, org.junit.jupiter.api.function.Executable executable) {
192 		var exception = Assertions.assertThrows(expectedType, executable);
193 		var messages = getMessages(exception);
194 		expectedSubstrings.stream().forEach(x -> assertTrue(messages.contains(x), fs("Expected message to contain: {0}.\nActual:\n{1}", x, messages)));
195 		return exception;
196 	}
197 
198 	public static <T extends Throwable> T assertThrowsWithMessage(Class<T> expectedType, String expectedSubstring, org.junit.jupiter.api.function.Executable executable) {
199 		var exception = Assertions.assertThrows(expectedType, executable);
200 		var messages = getMessages(exception);
201 		assertTrue(messages.contains(expectedSubstring), fs("Expected message to contain: {0}.\nActual:\n{1}", expectedSubstring, messages));
202 		return exception;
203 	}
204 
205 	/**
206 	 * Validates that the whitespace is correct in the specified XML.
207 	 */
208 	public static final void checkXmlWhitespace(String out) throws SerializeException {
209 		if (out.indexOf('\u0000') != -1) {
210 			for (var s : out.split("\u0000"))
211 				checkXmlWhitespace(s);
212 			return;
213 		}
214 
215 		var indent = -1;
216 		var startTag = Pattern.compile("^(\\s*)<[^/>]+(\\s+\\S+=['\"]\\S*['\"])*\\s*>$");  // NOSONAR
217 		var endTag = Pattern.compile("^(\\s*)</[^>]+>$");
218 		var combinedTag = Pattern.compile("^(\\s*)<[^>/]+(\\s+\\S+=['\"]\\S*['\"])*\\s*/>$");  // NOSONAR
219 		var contentOnly = Pattern.compile("^(\\s*)[^\\s\\<]+$");
220 		var tagWithContent = Pattern.compile("^(\\s*)<[^>]+>.*</[^>]+>$");
221 		var lines = out.split("\n");
222 		try {
223 			for (var i = 0; i < lines.length; i++) {
224 				var line = lines[i];
225 				var m = startTag.matcher(line);
226 				if (m.matches()) {
227 					indent++;
228 					if (m.group(1).length() != indent)
229 						throw new SerializeException("Wrong indentation detected on start tag line ''{0}''", i+1);
230 					continue;
231 				}
232 				m = endTag.matcher(line);
233 				if (m.matches()) {
234 					if (m.group(1).length() != indent)
235 						throw new SerializeException("Wrong indentation detected on end tag line ''{0}''", i+1);
236 					indent--;
237 					continue;
238 				}
239 				m = combinedTag.matcher(line);
240 				if (m.matches()) {
241 					indent++;
242 					if (m.group(1).length() != indent)
243 						throw new SerializeException("Wrong indentation detected on combined tag line ''{0}''", i+1);
244 					indent--;
245 					continue;
246 				}
247 				m = contentOnly.matcher(line);
248 				if (m.matches()) {
249 					indent++;
250 					if (m.group(1).length() != indent)
251 						throw new SerializeException("Wrong indentation detected on content-only line ''{0}''", i+1);
252 					indent--;
253 					continue;
254 				}
255 				m = tagWithContent.matcher(line);
256 				if (m.matches()) {
257 					indent++;
258 					if (m.group(1).length() != indent)
259 						throw new SerializeException("Wrong indentation detected on tag-with-content line ''{0}''", i+1);
260 					indent--;
261 					continue;
262 				}
263 				throw new SerializeException("Unmatched whitespace line at line number ''{0}''", i+1);
264 			}
265 			if (indent != -1)
266 				throw new SerializeException("Possible unmatched tag.  indent=''{0}''", indent);
267 		} catch (SerializeException e) {
268 			printLines(lines);
269 			throw e;
270 		}
271 	}
272 
273 	/**
274 	 * Returns the value of the specified field/property on the specified object.
275 	 * First looks for getter, then looks for field.
276 	 * Methods and fields can be any visibility.
277 	 */
278 	public static Object getBeanProp(Object o, String name) {
279 		return safe(() -> {
280 			var f = (Field)null;
281 			var c = o.getClass();
282 			var n = Character.toUpperCase(name.charAt(0)) + name.substring(1);
283 			var m = Arrays.stream(c.getMethods()).filter(x -> x.getName().equals("is"+n) && x.getParameterCount() == 0 && x.getAnnotation(BeanIgnore.class) == null).findFirst().orElse(null);
284 			if (m != null) {
285 				m.setAccessible(true);
286 				return m.invoke(o);
287 			}
288 			m = Arrays.stream(c.getMethods()).filter(x -> x.getName().equals("get"+n) && x.getParameterCount() == 0 && x.getAnnotation(BeanIgnore.class) == null).findFirst().orElse(null);
289 			if (m != null) {
290 				m.setAccessible(true);
291 				return m.invoke(o);
292 			}
293 			m = Arrays.stream(c.getMethods()).filter(x -> x.getName().equals("get") && x.getParameterCount() == 1 && x.getParameterTypes()[0] == String.class && x.getAnnotation(BeanIgnore.class) == null).findFirst().orElse(null);
294 			if (m != null) {
295 				m.setAccessible(true);
296 				return m.invoke(o, name);
297 			}
298 			var c2 = c;
299 			while (f == null && c2 != null) {
300 				f = Arrays.stream(c2.getDeclaredFields()).filter(x -> x.getName().equals(name)).findFirst().orElse(null);
301 				c2 = c2.getSuperclass();
302 			}
303 			if (f != null) {
304 				f.setAccessible(true);
305 				return f.get(o);
306 			}
307 			m = Arrays.stream(c.getMethods()).filter(x -> x.getName().equals(name) && x.getParameterCount() == 0).findFirst().orElse(null);
308 			if (m != null) {
309 				m.setAccessible(true);
310 				return m.invoke(o);
311 			}
312 			throw rex("Property {0} not found on object of type {1}", name, cn(o));
313 		});
314 	}
315 
316 	private static String getMessages(Throwable t) {
317 		return Stream.iterate(t, Throwable::getCause).takeWhile(e -> e != null).map(Throwable::getMessage).collect(joining("\n"));
318 	}
319 
320 	/**
321 	 * Gets the swagger for the specified @Resource-annotated object.
322 	 */
323 	public static Swagger getSwagger(Class<?> c) {
324 		try {
325 			var r = c.getDeclaredConstructor().newInstance();
326 			var rc = RestContext.create(r.getClass(),null,null).init(()->r).build();
327 			var ctx = RestOpContext.create(TestUtils.class.getMethod("getSwagger", Class.class), rc).build();
328 			var session = RestSession.create(rc).resource(r).req(new MockServletRequest()).res(new MockServletResponse()).build();
329 			var req = ctx.createRequest(session);
330 			var ip = rc.getSwaggerProvider();
331 			return ip.getSwagger(rc, req.getLocale());
332 		} catch (Exception e) {
333 			throw new RuntimeException(e);
334 		}
335 	}
336 
337 	/**
338 	 * Creates an input stream from the specified string.
339 	 *
340 	 * @param in The contents of the reader.
341 	 * @return A new input stream.
342 	 */
343 	public static final ByteArrayInputStream inputStream(String in) {
344 		return new ByteArrayInputStream(in.getBytes());
345 	}
346 
347 	public static String json(Object o) {
348 		return Json5.DEFAULT_SORTED.write(o);
349 	}
350 
351 	public static <T> T json(String o, Class<T> c) {
352 		return safe(()->Json5.DEFAULT_SORTED.read(o, c));
353 	}
354 
355 	public static <T> T jsonRoundTrip(T o, Class<T> c) {
356 		return json(json(o), c);
357 	}
358 
359 	/**
360 	 * Creates a reader from the specified string.
361 	 *
362 	 * @param in The contents of the reader.
363 	 * @return A new reader.
364 	 */
365 	public static final StringReader reader(String in) {
366 		return new StringReader(in);
367 	}
368 
369 	/**
370 	 * Temporarily sets the default system locale to the specified locale.
371 	 * Use {@link #unsetLocale()} to unset it.
372 	 *
373 	 * @param name
374 	 */
375 	public static final void setLocale(Locale v) {
376 		SYSTEM_LOCALE.set(Locale.getDefault());
377 		Locale.setDefault(v);
378 	}
379 
380 	/**
381 	 * Temporarily sets the default system timezone to the specified timezone ID.
382 	 * Use {@link #unsetTimeZone()} to unset it.
383 	 *
384 	 * @param name
385 	 */
386 	public static final synchronized void setTimeZone(String v) {
387 		SYSTEM_TIME_ZONE.set(TimeZone.getDefault());
388 		TimeZone.setDefault(TimeZone.getTimeZone(v));
389 	}
390 
391 	public static final void unsetLocale() {
392 		Locale.setDefault(SYSTEM_LOCALE.get());
393 	}
394 
395 	public static final synchronized void unsetTimeZone() {
396 		TimeZone.setDefault(SYSTEM_TIME_ZONE.get());
397 	}
398 
399 	/**
400 	 * Constructs a {@link URL} object from a string.
401 	 */
402 	public static URL url(String value) {
403 		return safe(()->new URI(value).toURL());
404 	}
405 
406 	/**
407 	 * Test whitespace and generated schema.
408 	 */
409 	public static final void validateXml(Object o) throws Exception {
410 		validateXml(o, XmlSerializer.DEFAULT_NS_SQ);
411 	}
412 
413 	/**
414 	 * Test whitespace and generated schema.
415 	 */
416 	public static final void validateXml(Object o, XmlSerializer s) throws Exception {
417 		s = s.copy().ws().ns().addNamespaceUrisToRoot().build();
418 		var xml = s.serialize(o);
419 		checkXmlWhitespace(xml);
420 	}
421 
422 	public static final <T> BeanTester<T> testBean(T bean) {
423 		return (BeanTester<T>) new BeanTester<>().bean(bean);
424 	}
425 
426 	/**
427 	 * Extracts HTML/XML elements from a string based on element name and attributes.
428 	 *
429 	 * <p>Uses a depth-tracking parser to handle nested elements correctly, even with malformed HTML.</p>
430 	 *
431 	 * <h5 class='section'>Examples:</h5>
432 	 * <pre>
433 	 * // Extract all div elements with class='tag-block'
434 	 * List&lt;String&gt; blocks = extractXml(html, "div", Map.of("class", "tag-block"));
435 	 *
436 	 * // Extract all span elements (no attribute filtering)
437 	 * List&lt;String&gt; spans = extractXml(html, "span", null);
438 	 *
439 	 * // Extract divs with multiple attributes
440 	 * List&lt;String&gt; divs = extractXml(html, "div", Map.of("class", "header", "id", "main"));
441 	 * </pre>
442 	 *
443 	 * @param html The HTML/XML content to parse
444 	 * @param elementName The element name to extract (e.g., "div", "span")
445 	 * @param withAttributes Optional map of attribute name/value pairs that must match.
446 	 *                       Pass null or empty map to match all elements of the given name.
447 	 * @return List of HTML content strings (inner content of matching elements)
448 	 */
449 	public static List<String> extractXml(String html, String elementName, Map<String,String> withAttributes) {
450 		List<String> results = list();  // NOAI
451 
452 		if (html == null || elementName == null) {
453 			return results;
454 		}
455 
456 		// Find all opening tags of the specified element
457 		var openTag = "<" + elementName;
458 		int searchPos = 0;
459 
460 		while ((searchPos = html.indexOf(openTag, searchPos)) != -1) {
461 			// Find the end of the opening tag
462 			int tagEnd = html.indexOf('>', searchPos);
463 			if (tagEnd == -1) break;
464 
465 			var fullOpenTag = html.substring(searchPos, tagEnd + 1);
466 
467 			// Check if attributes match
468 			var matches = true;
469 			if (withAttributes != null && !withAttributes.isEmpty()) {
470 				for (var entry : withAttributes.entrySet()) {
471 					var attrName = entry.getKey();
472 					var attrValue = entry.getValue();
473 
474 					// Look for attribute in the tag (handle both single and double quotes)
475 					var pattern1 = attrName + "=\"" + attrValue + "\"";
476 					var pattern2 = attrName + "='" + attrValue + "'";
477 
478 					if (!fullOpenTag.contains(pattern1) && !fullOpenTag.contains(pattern2)) {
479 						matches = false;
480 						break;
481 					}
482 				}
483 			}
484 
485 			if (matches) {
486 				// Find matching closing tag by tracking depth
487 				int contentStart = tagEnd + 1;
488 				int depth = 1;
489 				int pos = contentStart;
490 
491 				while (pos < html.length() && depth > 0) {
492 					// Look for next opening or closing tag of same element
493 					int nextOpen = html.indexOf("<" + elementName, pos);
494 					int nextClose = html.indexOf("</" + elementName + ">", pos);
495 
496 					// Validate that nextOpen is actually an opening tag
497 					if (nextOpen != -1 && (nextOpen < nextClose || nextClose == -1)) {
498 						if (nextOpen + elementName.length() + 1 < html.length()) {
499 							var nextChar = html.charAt(nextOpen + elementName.length() + 1);
500 							if (nextChar == ' ' || nextChar == '>' || nextChar == '/') {
501 								depth++;
502 								pos = nextOpen + elementName.length() + 1;
503 								continue;
504 							}
505 						}
506 						// Not a valid opening tag, skip it
507 						pos = nextOpen + 1;
508 						continue;
509 					}
510 
511 					if (nextClose != -1) {
512 						depth--;
513 						if (depth == 0) {
514 							// Found matching close tag
515 							results.add(html.substring(contentStart, nextClose));
516 							break;
517 						}
518 						pos = nextClose + elementName.length() + 3;
519 					} else {
520 						// No more closing tags
521 						break;
522 					}
523 				}
524 			}
525 
526 			searchPos = tagEnd + 1;
527 		}
528 
529 		return results;
530 	}
531 }