| 1 | package com.renomad.minum.web; | |
| 2 | ||
| 3 | import com.renomad.minum.state.Constants; | |
| 4 | import com.renomad.minum.htmlparsing.HtmlParseNode; | |
| 5 | import com.renomad.minum.htmlparsing.HtmlParser; | |
| 6 | import com.renomad.minum.htmlparsing.TagName; | |
| 7 | import com.renomad.minum.logging.ILogger; | |
| 8 | import com.renomad.minum.state.Context; | |
| 9 | import com.renomad.minum.utils.InvariantException; | |
| 10 | import com.renomad.minum.utils.StacktraceUtils; | |
| 11 | import java.io.IOException; | |
| 12 | import java.io.InputStream; | |
| 13 | import java.net.Socket; | |
| 14 | import java.nio.charset.StandardCharsets; | |
| 15 | import java.util.ArrayList; | |
| 16 | import java.util.List; | |
| 17 | import java.util.Map; | |
| 18 | import java.util.regex.Matcher; | |
| 19 | import java.util.regex.Pattern; | |
| 20 | ||
| 21 | import static com.renomad.minum.utils.Invariants.mustBeTrue; | |
| 22 | ||
| 23 | /** | |
| 24 | * Tools to enable system-wide integration testing | |
| 25 | */ | |
| 26 | public final class FunctionalTesting { | |
| 27 | ||
| 28 | private final String host; | |
| 29 | private final int port; | |
| 30 | private final IInputStreamUtils inputStreamUtils; | |
| 31 | private final ILogger logger; | |
| 32 | private final Constants constants; | |
| 33 | private final IBodyProcessor bodyProcessor; | |
| 34 | ||
| 35 | /** | |
| 36 | * Allows the user to set the host and port to target | |
| 37 | * for testing. | |
| 38 | */ | |
| 39 | public FunctionalTesting(Context context, String host, int port) { | |
| 40 | this.constants = context.getConstants(); | |
| 41 | this.host = host; | |
| 42 | this.port = port; | |
| 43 | ||
| 44 | this.inputStreamUtils = new InputStreamUtils(constants.maxReadLineSizeBytes); | |
| 45 | this.logger = context.getLogger(); | |
| 46 | bodyProcessor = new BodyProcessor(context); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * A {@link Response} designed to work with {@link FunctionalTesting} | |
| 51 | */ | |
| 52 | public record TestResponse(StatusLine statusLine, Headers headers, Body body) { | |
| 53 | ||
| 54 | public static final TestResponse EMPTY = new TestResponse(StatusLine.EMPTY, Headers.EMPTY, Body.EMPTY); | |
| 55 | ||
| 56 | /** | |
| 57 | * Presuming the response body is HTML, search for a single | |
| 58 | * HTML element with the given tag name and attributes. | |
| 59 | * | |
| 60 | * @return {@link HtmlParseNode#EMPTY} if none found, a particular node if found, | |
| 61 | * and an exception thrown if more than one found. | |
| 62 | */ | |
| 63 | public HtmlParseNode searchOne(TagName tagName, Map<String, String> attributes) { | |
| 64 | var htmlParser = new HtmlParser(); | |
| 65 | var nodes = htmlParser.parse(body.asString()); | |
| 66 | var searchResults = htmlParser.search(nodes, tagName, attributes); | |
| 67 |
2
1. searchOne : negated conditional → KILLED 2. searchOne : changed conditional boundary → KILLED |
if (searchResults.size() > 1) { |
| 68 | throw new InvariantException("More than 1 node found. Here they are:" + searchResults); | |
| 69 | } | |
| 70 |
1
1. searchOne : negated conditional → KILLED |
if (searchResults.isEmpty()) { |
| 71 |
1
1. searchOne : replaced return value with null for com/renomad/minum/web/FunctionalTesting$TestResponse::searchOne → KILLED |
return HtmlParseNode.EMPTY; |
| 72 | } else { | |
| 73 |
1
1. searchOne : replaced return value with null for com/renomad/minum/web/FunctionalTesting$TestResponse::searchOne → KILLED |
return searchResults.getFirst(); |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Presuming the response body is HTML, search for all | |
| 79 | * HTML elements with the given tag name and attributes. | |
| 80 | * | |
| 81 | * @return a list of however many elements matched | |
| 82 | */ | |
| 83 | public List<HtmlParseNode> search(TagName tagName, Map<String, String> attributes) { | |
| 84 | var htmlParser = new HtmlParser(); | |
| 85 | var nodes = htmlParser.parse(body.asString()); | |
| 86 |
1
1. search : replaced return value with Collections.emptyList for com/renomad/minum/web/FunctionalTesting$TestResponse::search → SURVIVED |
return htmlParser.search(nodes, tagName, attributes); |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Send a GET request (as a client to the server) | |
| 92 | * @param path the path to an endpoint, that is, the value for path | |
| 93 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 94 | * for pathname | |
| 95 | */ | |
| 96 | public TestResponse get(String path) { | |
| 97 | ArrayList<String> headers = new ArrayList<>(); | |
| 98 |
1
1. get : replaced return value with null for com/renomad/minum/web/FunctionalTesting::get → KILLED |
return send(RequestLine.Method.GET, path, new byte[0], headers); |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Send a GET request (as a client to the server) | |
| 103 | * @param path the path to an endpoint, that is, the value for path | |
| 104 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 105 | * for pathname | |
| 106 | * @param extraHeaders a list containing extra headers you need the client to send, for | |
| 107 | * example, <pre>{@code List.of("cookie: id=foo")}</pre> | |
| 108 | */ | |
| 109 | public TestResponse get(String path, List<String> extraHeaders) { | |
| 110 |
1
1. get : replaced return value with null for com/renomad/minum/web/FunctionalTesting::get → KILLED |
return send(RequestLine.Method.GET, path, new byte[0], extraHeaders); |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Send a POST request (as a client to the server) using url encoding | |
| 115 | * @param path the path to an endpoint, that is, the value for path | |
| 116 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 117 | * for pathname | |
| 118 | * @param payload a body payload in string form | |
| 119 | */ | |
| 120 | public TestResponse post(String path, String payload) { | |
| 121 | ArrayList<String> headers = new ArrayList<>(); | |
| 122 |
1
1. post : replaced return value with null for com/renomad/minum/web/FunctionalTesting::post → KILLED |
return post(path, payload, headers); |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Send a POST request (as a client to the server) using url encoding | |
| 127 | * @param path the path to an endpoint, that is, the value for path | |
| 128 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 129 | * for pathname | |
| 130 | * @param payload a body payload in string form | |
| 131 | * @param extraHeaders a list containing extra headers you need the client to send, for | |
| 132 | * example, <pre>{@code List.of("cookie: id=foo")}</pre> | |
| 133 | */ | |
| 134 | public TestResponse post(String path, String payload, List<String> extraHeaders) { | |
| 135 | var headers = new ArrayList<String>(); | |
| 136 | headers.add("Content-Type: application/x-www-form-urlencoded"); | |
| 137 | headers.addAll(extraHeaders); | |
| 138 |
1
1. post : replaced return value with null for com/renomad/minum/web/FunctionalTesting::post → KILLED |
return send(RequestLine.Method.POST, path, payload.getBytes(StandardCharsets.UTF_8), headers); |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Send a request as a client to the server | |
| 143 | * <p> | |
| 144 | * This helper method is the same as {@link #send(RequestLine.Method, String, byte[], List)} except | |
| 145 | * it will set the body as empty and does not require any extra headers to be set. In this | |
| 146 | * case, the headers sent are very minimal. See the source. | |
| 147 | * </p> | |
| 148 | * @param path the path to an endpoint, that is, the value for path | |
| 149 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 150 | * for pathname | |
| 151 | */ | |
| 152 | public TestResponse send(RequestLine.Method method, String path) { | |
| 153 |
1
1. send : replaced return value with null for com/renomad/minum/web/FunctionalTesting::send → KILLED |
return send(method, path, new byte[0], List.of()); |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Send a request as a client to the server | |
| 158 | * @param path the path to an endpoint, that is, the value for path | |
| 159 | * that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} | |
| 160 | * for pathname | |
| 161 | * @param payload a body payload in byte array form | |
| 162 | * @param extraHeaders a list containing extra headers you need the client to send, for | |
| 163 | * example, <pre>{@code List.of("cookie: id=foo")}</pre> | |
| 164 | * @return a properly-built client, or null if exceptions occur | |
| 165 | */ | |
| 166 | public TestResponse send(RequestLine.Method method, String path, byte[] payload, List<String> extraHeaders) { | |
| 167 | try (Socket socket = new Socket(host, port)) { | |
| 168 | try (ISocketWrapper client = startClient(socket)) { | |
| 169 | return innerClientSend(client, method, path, payload, extraHeaders); | |
| 170 | } | |
| 171 | } catch (Exception e) { | |
| 172 | logger.logDebug(() -> "Error during client send: " + StacktraceUtils.stackTraceToString(e)); | |
| 173 |
1
1. send : replaced return value with null for com/renomad/minum/web/FunctionalTesting::send → KILLED |
return TestResponse.EMPTY; |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Create a client {@link ISocketWrapper} connected to the running host server | |
| 179 | */ | |
| 180 | ISocketWrapper startClient(Socket socket) throws IOException { | |
| 181 | logger.logDebug(() -> String.format("Just created new client socket: %s", socket)); | |
| 182 |
1
1. startClient : replaced return value with null for com/renomad/minum/web/FunctionalTesting::startClient → KILLED |
return new SocketWrapper(socket, null, logger, constants.socketTimeoutMillis, constants.hostName); |
| 183 | } | |
| 184 | ||
| 185 | public TestResponse innerClientSend( | |
| 186 | ISocketWrapper client, | |
| 187 | RequestLine.Method method, | |
| 188 | String path, | |
| 189 | byte[] payload, | |
| 190 | List<String> extraHeaders) throws IOException { | |
| 191 | Body body = Body.EMPTY; | |
| 192 | ||
| 193 | InputStream is = client.getInputStream(); | |
| 194 | ||
| 195 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED |
client.sendHttpLine(method + " /" + path + " HTTP/1.1"); |
| 196 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → TIMED_OUT |
client.sendHttpLine(String.format("Host: %s:%d", host, port)); |
| 197 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED |
client.sendHttpLine("Content-Length: " + payload.length); |
| 198 | for (String header : extraHeaders) { | |
| 199 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED |
client.sendHttpLine(header); |
| 200 | } | |
| 201 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED |
client.sendHttpLine(""); |
| 202 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::send → TIMED_OUT |
client.send(payload); |
| 203 |
1
1. innerClientSend : removed call to com/renomad/minum/web/ISocketWrapper::flush → TIMED_OUT |
client.flush(); |
| 204 | ||
| 205 | StatusLine statusLine = extractStatusLine(inputStreamUtils.readLine(is)); | |
| 206 | List<String> allHeaders = Headers.getAllHeaders(is, inputStreamUtils); | |
| 207 | Headers headers = new Headers(allHeaders); | |
| 208 |
1
1. innerClientSend : negated conditional → TIMED_OUT |
boolean hasBody = !headers.contentType().isBlank(); |
| 209 | ||
| 210 |
2
1. innerClientSend : negated conditional → TIMED_OUT 2. innerClientSend : negated conditional → TIMED_OUT |
if (hasBody && method != RequestLine.Method.HEAD) { |
| 211 | logger.logTrace(() -> "There is a body. Content-type is " + headers.contentType()); | |
| 212 | body = bodyProcessor.extractData(is, headers); | |
| 213 | } | |
| 214 |
1
1. innerClientSend : replaced return value with null for com/renomad/minum/web/FunctionalTesting::innerClientSend → KILLED |
return new TestResponse(statusLine, headers, body); |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * This is the regex used to analyze a status line sent by the server and | |
| 219 | * read by the client. Servers will send messages like: "HTTP/1.1 200 OK" or "HTTP/1.1 500 Internal Server Error" | |
| 220 | */ | |
| 221 | static final String statusLinePattern = "^HTTP/(...) (\\d{3}) (.*)$"; | |
| 222 | static final Pattern statusLineRegex = Pattern.compile(statusLinePattern); | |
| 223 | ||
| 224 | /** | |
| 225 | * Parses a string value of a status line from an HTTP | |
| 226 | * server. If the input value is null or empty, we'll | |
| 227 | * return a {@link StatusLine} with null-object values | |
| 228 | */ | |
| 229 | public static StatusLine extractStatusLine(String value) { | |
| 230 |
2
1. extractStatusLine : negated conditional → TIMED_OUT 2. extractStatusLine : negated conditional → KILLED |
if (value == null || value.isBlank()) { |
| 231 |
1
1. extractStatusLine : replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED |
return StatusLine.EMPTY; |
| 232 | } | |
| 233 | Matcher mr = statusLineRegex.matcher(value); | |
| 234 | mustBeTrue(mr.matches(), String.format("%s must match the statusLinePattern: %s", value, statusLinePattern)); | |
| 235 | String version = mr.group(1); | |
| 236 | HttpVersion httpVersion = switch (version) { | |
| 237 | case "1.1" -> HttpVersion.ONE_DOT_ONE; | |
| 238 | case "1.0" -> HttpVersion.ONE_DOT_ZERO; | |
| 239 | default -> throw new WebServerException(String.format("HTTP version was not an acceptable value. Given: %s", version)); | |
| 240 | }; | |
| 241 | StatusLine.StatusCode status = StatusLine.StatusCode.findByCode(Integer.parseInt(mr.group(2))); | |
| 242 | ||
| 243 |
1
1. extractStatusLine : replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED |
return new StatusLine(status, httpVersion, value); |
| 244 | } | |
| 245 | ||
| 246 | } | |
Mutations | ||
| 67 |
1.1 2.2 |
|
| 70 |
1.1 |
|
| 71 |
1.1 |
|
| 73 |
1.1 |
|
| 86 |
1.1 |
|
| 98 |
1.1 |
|
| 110 |
1.1 |
|
| 122 |
1.1 |
|
| 138 |
1.1 |
|
| 153 |
1.1 |
|
| 173 |
1.1 |
|
| 182 |
1.1 |
|
| 195 |
1.1 |
|
| 196 |
1.1 |
|
| 197 |
1.1 |
|
| 199 |
1.1 |
|
| 201 |
1.1 |
|
| 202 |
1.1 |
|
| 203 |
1.1 |
|
| 208 |
1.1 |
|
| 210 |
1.1 2.2 |
|
| 214 |
1.1 |
|
| 230 |
1.1 2.2 |
|
| 231 |
1.1 |
|
| 243 |
1.1 |