FunctionalTesting.java

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
Location : searchOne
Killed by : com.renomad.minum.FunctionalTests
negated conditional → KILLED

2.2
Location : searchOne
Killed by : com.renomad.minum.FunctionalTests
changed conditional boundary → KILLED

70

1.1
Location : searchOne
Killed by : com.renomad.minum.FunctionalTests
negated conditional → KILLED

71

1.1
Location : searchOne
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting$TestResponse::searchOne → KILLED

73

1.1
Location : searchOne
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting$TestResponse::searchOne → KILLED

86

1.1
Location : search
Killed by : none
replaced return value with Collections.emptyList for com/renomad/minum/web/FunctionalTesting$TestResponse::search → SURVIVED
Covering tests

98

1.1
Location : get
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::get → KILLED

110

1.1
Location : get
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::get → KILLED

122

1.1
Location : post
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::post → KILLED

138

1.1
Location : post
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::post → KILLED

153

1.1
Location : send
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::send → KILLED

173

1.1
Location : send
Killed by : none
replaced return value with null for com/renomad/minum/web/FunctionalTesting::send → TIMED_OUT

182

1.1
Location : startClient
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::startClient → KILLED

195

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED

196

1.1
Location : innerClientSend
Killed by : none
removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → SURVIVED
Covering tests

197

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED

199

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED

201

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
removed call to com/renomad/minum/web/ISocketWrapper::sendHttpLine → KILLED

202

1.1
Location : innerClientSend
Killed by : none
removed call to com/renomad/minum/web/ISocketWrapper::send → TIMED_OUT

209

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
negated conditional → KILLED

2.2
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
negated conditional → KILLED

213

1.1
Location : innerClientSend
Killed by : com.renomad.minum.FunctionalTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::innerClientSend → KILLED

229

1.1
Location : extractStatusLine
Killed by : com.renomad.minum.web.WebTests
negated conditional → KILLED

2.2
Location : extractStatusLine
Killed by : com.renomad.minum.web.WebTests
negated conditional → KILLED

230

1.1
Location : extractStatusLine
Killed by : com.renomad.minum.web.WebTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED

242

1.1
Location : extractStatusLine
Killed by : com.renomad.minum.web.WebTests
replaced return value with null for com/renomad/minum/web/FunctionalTesting::extractStatusLine → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0