FunctionalTesting.java

package com.renomad.minum.web;

import com.renomad.minum.state.Constants;
import com.renomad.minum.htmlparsing.HtmlParseNode;
import com.renomad.minum.htmlparsing.HtmlParser;
import com.renomad.minum.htmlparsing.TagName;
import com.renomad.minum.logging.ILogger;
import com.renomad.minum.state.Context;
import com.renomad.minum.utils.InvariantException;
import com.renomad.minum.utils.StacktraceUtils;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Tools to enable system-wide integration testing
 */
public final class FunctionalTesting {

    private final String host;
    private final int port;
    private final IInputStreamUtils inputStreamUtils;
    private final ILogger logger;
    private final Constants constants;
    private final IBodyProcessor bodyProcessor;

    /**
     * Allows the user to set the host and port to target
     * for testing.
     */
    public FunctionalTesting(Context context, String host, int port) {
        this.constants = context.getConstants();
        this.host = host;
        this.port = port;

        this.inputStreamUtils = new InputStreamUtils(constants.maxReadLineSizeBytes);
        this.logger = context.getLogger();
        bodyProcessor = new BodyProcessor(context);
    }

    /**
     * A {@link Response} designed to work with {@link FunctionalTesting}
     */
    public record TestResponse(StatusLine statusLine, Headers headers, Body body) {

        public static final TestResponse EMPTY = new TestResponse(StatusLine.EMPTY, Headers.EMPTY, Body.EMPTY);

        /**
         * Presuming the response body is HTML, search for a single
         * HTML element with the given tag name and attributes.
         *
         * @return {@link HtmlParseNode#EMPTY} if none found, a particular node if found,
         * and an exception thrown if more than one found.
         */
        public HtmlParseNode searchOne(TagName tagName, Map<String, String> attributes) {
            var htmlParser = new HtmlParser();
            var nodes = htmlParser.parse(body.asString());
            var searchResults = htmlParser.search(nodes, tagName, attributes);
            if (searchResults.size() > 1) {
                throw new InvariantException("More than 1 node found.  Here they are:" + searchResults);
            }
            if (searchResults.isEmpty()) {
                return HtmlParseNode.EMPTY;
            } else {
                return searchResults.getFirst();
            }
        }

        /**
         * Presuming the response body is HTML, search for all
         * HTML elements with the given tag name and attributes.
         *
         * @return a list of however many elements matched
         */
        public List<HtmlParseNode> search(TagName tagName, Map<String, String> attributes) {
            var htmlParser = new HtmlParser();
            var nodes = htmlParser.parse(body.asString());
            return htmlParser.search(nodes, tagName, attributes);
        }
    }

    /**
     * Send a GET request (as a client to the server)
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     */
    public TestResponse get(String path) {
        ArrayList<String> headers = new ArrayList<>();
        return send(RequestLine.Method.GET, path, new byte[0], headers);
    }

    /**
     * Send a GET request (as a client to the server)
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     * @param extraHeaders a list containing extra headers you need the client to send, for
     *                     example, <pre>{@code List.of("cookie: id=foo")}</pre>
     */
    public TestResponse get(String path, List<String> extraHeaders) {
        return send(RequestLine.Method.GET, path, new byte[0], extraHeaders);
    }

    /**
     * Send a POST request (as a client to the server) using url encoding
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     * @param payload a body payload in string form
     */
    public TestResponse post(String path, String payload) {
        ArrayList<String> headers = new ArrayList<>();
        return post(path, payload, headers);
    }

    /**
     * Send a POST request (as a client to the server) using url encoding
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     * @param payload a body payload in string form
     * @param extraHeaders a list containing extra headers you need the client to send, for
     *                     example, <pre>{@code List.of("cookie: id=foo")}</pre>
     */
    public TestResponse post(String path, String payload, List<String> extraHeaders) {
        var headers = new ArrayList<String>();
        headers.add("Content-Type: application/x-www-form-urlencoded");
        headers.addAll(extraHeaders);
        return send(RequestLine.Method.POST, path, payload.getBytes(StandardCharsets.UTF_8), headers);
    }

    /**
     * Send a request as a client to the server
     * <p>
     *     This helper method is the same as {@link #send(RequestLine.Method, String, byte[], List)} except
     *     it will set the body as empty and does not require any extra headers to be set. In this
     *     case, the headers sent are very minimal.  See the source.
     * </p>
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     */
    public TestResponse send(RequestLine.Method method, String path) {
        return send(method, path, new byte[0], List.of());
    }

    /**
     * Send a request as a client to the server
     * @param path the path to an endpoint, that is, the value for path
     *            that is entered in {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)}
     *             for pathname
     * @param payload a body payload in byte array form
     * @param extraHeaders a list containing extra headers you need the client to send, for
     *                     example, <pre>{@code List.of("cookie: id=foo")}</pre>
     * @return a properly-built client, or null if exceptions occur
     */
    public TestResponse send(RequestLine.Method method, String path, byte[] payload, List<String> extraHeaders) {
        try (Socket socket = new Socket(host, port)) {
            try (ISocketWrapper client = startClient(socket)) {
                return innerClientSend(client, method, path, payload, extraHeaders);
            }
        } catch (Exception e) {
            logger.logDebug(() -> "Error during client send: " + StacktraceUtils.stackTraceToString(e));
            return TestResponse.EMPTY;
        }
    }

    /**
     * Create a client {@link ISocketWrapper} connected to the running host server
     */
    ISocketWrapper startClient(Socket socket) throws IOException {
        logger.logDebug(() -> String.format("Just created new client socket: %s", socket));
        return new SocketWrapper(socket, null, logger, constants.socketTimeoutMillis, constants.hostName);
    }

    public TestResponse innerClientSend(
            ISocketWrapper client,
            RequestLine.Method method,
            String path,
            byte[] payload,
            List<String> extraHeaders) throws IOException {
        Body body = Body.EMPTY;

        InputStream is = client.getInputStream();

        client.sendHttpLine(method + " /" + path + " HTTP/1.1");
        client.sendHttpLine(String.format("Host: %s:%d", host, port));
        client.sendHttpLine("Content-Length: " + payload.length);
        for (String header : extraHeaders) {
            client.sendHttpLine(header);
        }
        client.sendHttpLine("");
        client.send(payload);

        StatusLine statusLine = StatusLine.extractStatusLine(inputStreamUtils.readLine(is));
        List<String> allHeaders = Headers.getAllHeaders(is, inputStreamUtils);
        Headers headers = new Headers(allHeaders);


        if (WebFramework.isThereIsABody(headers) && method != RequestLine.Method.HEAD) {
            logger.logTrace(() -> "There is a body. Content-type is " + headers.contentType());
            body = bodyProcessor.extractData(is, headers);
        }
        return new TestResponse(statusLine, headers, body);
    }


}