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 → TIMED_OUT |
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 → SURVIVED |
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 | ||
204 | StatusLine statusLine = extractStatusLine(inputStreamUtils.readLine(is)); | |
205 | List<String> allHeaders = Headers.getAllHeaders(is, inputStreamUtils); | |
206 | Headers headers = new Headers(allHeaders); | |
207 | ||
208 | ||
209 |
2
1. innerClientSend : negated conditional → KILLED 2. innerClientSend : negated conditional → KILLED |
if (WebFramework.isThereIsABody(headers) && method != RequestLine.Method.HEAD) { |
210 | logger.logTrace(() -> "There is a body. Content-type is " + headers.contentType()); | |
211 | body = bodyProcessor.extractData(is, headers); | |
212 | } | |
213 |
1
1. innerClientSend : replaced return value with null for com/renomad/minum/web/FunctionalTesting::innerClientSend → KILLED |
return new TestResponse(statusLine, headers, body); |
214 | } | |
215 | ||
216 | /** | |
217 | * This is the regex used to analyze a status line sent by the server and | |
218 | * read by the client. Servers will send messages like: "HTTP/1.1 200 OK" or "HTTP/1.1 500 Internal Server Error" | |
219 | */ | |
220 | static final String statusLinePattern = "^HTTP/(...) (\\d{3}) (.*)$"; | |
221 | static final Pattern statusLineRegex = Pattern.compile(statusLinePattern); | |
222 | ||
223 | /** | |
224 | * Parses a string value of a status line from an HTTP | |
225 | * server. If the input value is null or empty, we'll | |
226 | * return a {@link StatusLine} with null-object values | |
227 | */ | |
228 | public static StatusLine extractStatusLine(String value) { | |
229 |
2
1. extractStatusLine : negated conditional → KILLED 2. extractStatusLine : negated conditional → KILLED |
if (value == null || value.isBlank()) { |
230 |
1
1. extractStatusLine : replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED |
return StatusLine.EMPTY; |
231 | } | |
232 | Matcher mr = statusLineRegex.matcher(value); | |
233 | mustBeTrue(mr.matches(), String.format("%s must match the statusLinePattern: %s", value, statusLinePattern)); | |
234 | String version = mr.group(1); | |
235 | HttpVersion httpVersion = switch (version) { | |
236 | case "1.1" -> HttpVersion.ONE_DOT_ONE; | |
237 | case "1.0" -> HttpVersion.ONE_DOT_ZERO; | |
238 | default -> throw new WebServerException(String.format("HTTP version was not an acceptable value. Given: %s", version)); | |
239 | }; | |
240 | StatusLine.StatusCode status = StatusLine.StatusCode.findByCode(Integer.parseInt(mr.group(2))); | |
241 | ||
242 |
1
1. extractStatusLine : replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED |
return new StatusLine(status, httpVersion, value); |
243 | } | |
244 | ||
245 | } | |
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 |
|
209 |
1.1 2.2 |
|
213 |
1.1 |
|
229 |
1.1 2.2 |
|
230 |
1.1 |
|
242 |
1.1 |