// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.cloud.functions.invoker;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;

import com.google.auto.value.AutoValue;
import com.google.cloud.functions.invoker.runner.Invoker;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.io.Resources;
import com.google.common.truth.Expect;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonReader;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.http.HttpMessageFactory;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.URI;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import org.eclipse.jetty.client.ByteBufferRequestContent;
import org.eclipse.jetty.client.ContentResponse;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.MultiPartRequestContent;
import org.eclipse.jetty.client.Request;
import org.eclipse.jetty.client.StringRequestContent;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.MultiPart;
import org.eclipse.jetty.http.MultiPart.ContentSourcePart;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestName;

/**
 * Integration test that starts up a web server running the Function Framework and sends HTTP
 * requests to it.
 */
public class IntegrationTest {
  @Rule public final Expect expect = Expect.create();
  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
  @Rule public final TestName testName = new TestName();

  private static final String SERVER_READY_STRING = "Started oejs.ServerConnector";
  private static final String EXECUTION_ID_HTTP_HEADER = "HTTP_FUNCTION_EXECUTION_ID";
  private static final String EXECUTION_ID = "1234abcd";

  private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();

  private static String sampleLegacyEvent(File snoopFile) {
    return "{\n"
        + "  \"data\": {\n"
        + "    \"a\": 2,\n"
        + "    \"b\": 3,\n"
        + "    \"targetFile\": \""
        + snoopFile
        + "\""
        + "  },\n"
        + "  \"context\": {\n"
        + "    \"eventId\": \"B234-1234-1234\",\n"
        + "    \"timestamp\": \"2018-04-05T17:31:00Z\",\n"
        + "    \"eventType\": \"google.pubsub.topic.publish\",\n"
        + "    \"resource\": {\n"
        + "      \"service\":\"pubsub.googleapis.com\",\n"
        + "      \"name\":\"projects/sample-project/topics/gcf-test\",\n"
        + "      \"type\":\"type.googleapis.com/google.pubsub.v1.PubsubMessage\"\n"
        + "    }\n"
        + "  }\n"
        + "}";
  }

  private static CloudEvent sampleCloudEvent(File snoopFile) {
    return CloudEventBuilder.v1()
        .withId("B234-1234-1234")
        .withSource(URI.create("//pubsub.googleapis.com/projects/sample-project/topics/gcf-test"))
        .withSubject("documents/gcf-test/2Vm2mI1d0wIaK2Waj5to")
        .withType("google.cloud.pubsub.topic.v1.messagePublished")
        .withDataSchema(URI.create("/schema"))
        .withDataContentType("application/json")
        .withData(("{\"a\": 2, \"b\": 3, \"targetFile\": \"" + snoopFile + "\"}").getBytes(UTF_8))
        .withTime(OffsetDateTime.of(2018, 4, 5, 17, 31, 0, 0, ZoneOffset.UTC))
        .build();
  }

  private static JsonObject expectedCloudEventAttributes() {
    JsonObject attributes = new JsonObject();
    attributes.addProperty("datacontenttype", "application/json");
    attributes.addProperty("specversion", "1.0");
    attributes.addProperty("id", "B234-1234-1234");
    attributes.addProperty("source", "/source");
    attributes.addProperty("time", "2018-04-05T17:31Z");
    attributes.addProperty("type", "com.example.someevent.new");
    attributes.addProperty("dataschema", "/schema");
    return attributes;
  }

  private static int serverPort;

  /**
   * Each test method will start up a server on the same port, make one or more HTTP requests to
   * that port, then kill the server. So the port should be free when the next test method runs.
   */
  @BeforeClass
  public static void allocateServerPort() throws IOException {
    try (ServerSocket serverSocket = new ServerSocket(0)) {
      serverPort = serverSocket.getLocalPort();
    }
  }

  /**
   * Description of a test case. When we send an HTTP POST to the given {@link #url()} in the
   * server, with the given {@link #requestContent()} ()} as the body of the POST, then we expect to
   * get back the given {@link #expectedResponseText()} in the body of the response.
   */
  @AutoValue
  abstract static class TestCase {

    abstract String url();

    abstract Request.Content requestContent();

    abstract int expectedResponseCode();

    /**
     * Expected response headers map, header name -> value. Value "*" asserts the header is present
     * with any value. Value "-" asserts the header is not present.
     *
     * @return the expected response headers for this test case.
     */
    abstract Optional<Map<String, String>> expectedResponseHeaders();

    abstract Optional<String> expectedResponseText();

    abstract Optional<JsonObject> expectedJson();

    abstract Optional<String> expectedContentType();

    abstract Optional<String> expectedOutput();

    abstract Optional<String> httpContentType();

    abstract ImmutableMap<String, String> httpHeaders();

    abstract Optional<File> snoopFile();

    static Builder builder() {
      return new AutoValue_IntegrationTest_TestCase.Builder()
          .setUrl("/")
          .setRequestText("")
          .setExpectedResponseCode(HttpStatus.OK_200)
          .setExpectedResponseHeaders(ImmutableMap.of())
          .setExpectedResponseText("")
          .setHttpContentType("text/plain")
          .setHttpHeaders(ImmutableMap.of());
    }

    @AutoValue.Builder
    abstract static class Builder {

      abstract Builder setUrl(String x);

      abstract Builder setRequestContent(Request.Content x);

      Builder setRequestText(String text) {
        return setRequestContent(new StringRequestContent(text));
      }

      abstract Builder setExpectedResponseCode(int x);

      abstract Builder setExpectedResponseHeaders(Map<String, String> x);

      abstract Builder setExpectedResponseText(String x);

      abstract Builder setExpectedResponseText(Optional<String> x);

      abstract Builder setExpectedContentType(String x);

      abstract Builder setExpectedOutput(String x);

      abstract Builder setExpectedJson(JsonObject x);

      abstract Builder setHttpContentType(String x);

      abstract Builder setHttpContentType(Optional<String> x);

      abstract Builder setHttpHeaders(ImmutableMap<String, String> x);

      abstract Builder setSnoopFile(File x);

      abstract TestCase build();
    }
  }

  private static String fullTarget(String nameWithoutPackage) {
    return "com.google.cloud.functions.invoker.testfunctions." + nameWithoutPackage;
  }

  private static final TestCase FAVICON_TEST_CASE =
      TestCase.builder()
          .setUrl("/favicon.ico?foo=bar")
          .setExpectedResponseCode(HttpStatus.NOT_FOUND_404)
          .setExpectedResponseText(Optional.empty())
          .build();

  private static final TestCase ROBOTS_TXT_TEST_CASE =
      TestCase.builder()
          .setUrl("/robots.txt?foo=bar")
          .setExpectedResponseCode(HttpStatus.NOT_FOUND_404)
          .setExpectedResponseText(Optional.empty())
          .build();

  @Test
  public void helloWorld() throws Exception {
    testHttpFunction(
        fullTarget("HelloWorld"),
        ImmutableList.of(
            TestCase.builder()
                .setExpectedResponseHeaders(ImmutableMap.of("Content-Length", "*"))
                .setExpectedResponseText("hello\n")
                .build(),
            FAVICON_TEST_CASE,
            ROBOTS_TXT_TEST_CASE));
  }

  @Test
  public void timeoutHttpSuccess() throws Exception {
    testFunction(
        SignatureType.HTTP,
        fullTarget("TimeoutHttp"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder()
                .setExpectedResponseText("finished\n")
                .setExpectedResponseText(Optional.empty())
                .build()),
        ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "3"));
  }

  @Test
  public void timeoutHttpTimesOut() throws Exception {
    testFunction(
        SignatureType.HTTP,
        fullTarget("TimeoutHttp"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder()
                .setExpectedResponseCode(408)
                .setExpectedResponseText(Optional.empty())
                .build()),
        ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "1"));
  }

  @Test
  public void bufferedWrites() throws Exception {
    // This test checks that writes are buffered, and are written
    // in an efficient way with known content-length if possible
    // instead of doing a chunked response.
    testHttpFunction(
        fullTarget("BufferedWrites"),
        ImmutableList.of(
            TestCase.builder()
                .setUrl("/target?writes=2")
                .setExpectedResponseText("write 0\nwrite 1\n")
                .setExpectedResponseHeaders(
                    ImmutableMap.of(
                        "x-write-0", "true",
                        "x-write-1", "true",
                        "x-written", "true",
                        "Content-Length", "16"))
                .build(),
            TestCase.builder()
                .setUrl("/target?writes=2&flush=true")
                .setExpectedResponseText("write 0\nwrite 1\n")
                .setExpectedResponseHeaders(
                    ImmutableMap.of(
                        "x-write-0", "true",
                        "x-write-1", "true",
                        "x-written", "-",
                        "Transfer-Encoding", "chunked"))
                .build()));
  }

  @Test
  public void exceptionHttp() throws Exception {
    String exceptionExpectedOutput =
        "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":"
            + " \"com/google/cloud/functions/invoker/HttpFunctionExecutor.java\", \"method\":"
            + " \"handle\"}, \"execution_id\": \""
            + EXECUTION_ID
            + "\","
            + " \"message\": \"Failed to execute"
            + " com.google.cloud.functions.invoker.testfunctions.ExceptionHttp\\n"
            + "java.lang.RuntimeException: exception thrown for test";
    testHttpFunction(
        fullTarget("ExceptionHttp"),
        ImmutableList.of(
            TestCase.builder()
                .setExpectedResponseCode(500)
                .setExpectedResponseText("")
                .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
                .setExpectedOutput(exceptionExpectedOutput)
                .build()));
  }

  @Test
  public void exceptionBackground() throws Exception {
    String exceptionExpectedOutput =
        "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":"
            + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":"
            + " \"handle\"}, \"execution_id\": \""
            + EXECUTION_ID
            + "\", "
            + "\"message\": \"Failed to execute"
            + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n"
            + "java.lang.RuntimeException: exception thrown for test";

    File snoopFile = snoopFile();
    String gcfRequestText = sampleLegacyEvent(snoopFile);

    testFunction(
        SignatureType.BACKGROUND,
        fullTarget("ExceptionBackground"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder()
                .setRequestText(gcfRequestText)
                .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
                .setExpectedResponseCode(500)
                .setExpectedOutput(exceptionExpectedOutput)
                .build()),
        Collections.emptyMap());
  }

  @Test
  public void echo() throws Exception {
    String testText = "hello\nworld\n";
    testHttpFunction(
        fullTarget("Echo"),
        ImmutableList.of(
            TestCase.builder()
                .setRequestText(testText)
                .setExpectedResponseText(testText)
                .setExpectedContentType("text/plain")
                .build(),
            TestCase.builder()
                .setHttpContentType("application/octet-stream")
                .setRequestText(testText)
                .setExpectedResponseText(testText)
                .setExpectedContentType("application/octet-stream")
                .build()));
  }

  @Test
  public void echoUrl() throws Exception {
    String[] testUrls = {"/", "/foo/bar", "/?foo=bar&baz=buh", "/foo?bar=baz"};
    List<TestCase> testCases =
        Arrays.stream(testUrls)
            .map(url -> TestCase.builder().setUrl(url).setExpectedResponseText(url + "\n").build())
            .collect(toList());
    testHttpFunction(fullTarget("EchoUrl"), testCases);
  }

  @Test
  public void stackDriverLogging() throws Exception {
    String simpleExpectedOutput =
        "{\"severity\": \"INFO\", "
            + "\"logging.googleapis.com/sourceLocation\": "
            + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\","
            + " \"method\": \"service\"},"
            + " \"execution_id\": \""
            + EXECUTION_ID
            + "\","
            + " \"message\": \"blim\"}";
    TestCase simpleTestCase =
        TestCase.builder()
            .setUrl("/?message=blim")
            .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
            .setExpectedOutput(simpleExpectedOutput)
            .build();
    String quotingExpectedOutput = "\"message\": \"foo\\nbar\\\"";
    TestCase quotingTestCase =
        TestCase.builder()
            .setUrl("/?message=" + URLEncoder.encode("foo\nbar\"", "UTF-8"))
            .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
            .setExpectedOutput(quotingExpectedOutput)
            .build();
    String exceptionExpectedOutput =
        "{\"severity\": \"ERROR\", "
            + "\"logging.googleapis.com/sourceLocation\": "
            + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\", "
            + "\"method\": \"service\"}, "
            + "\"execution_id\": \""
            + EXECUTION_ID
            + "\", "
            + "\"message\": \"oops\\njava.lang.Exception: disaster\\n"
            + "	at com.google.cloud.functions.invoker.testfunctions.Log.service(Log.java:";
    TestCase exceptionTestCase =
        TestCase.builder()
            .setUrl("/?message=oops&level=severe&exception=disaster")
            .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
            .setExpectedOutput(exceptionExpectedOutput)
            .build();
    testHttpFunction(
        fullTarget("Log"), ImmutableList.of(simpleTestCase, quotingTestCase, exceptionTestCase));
  }

  private static int getJavaVersion() {
    String version = System.getProperty("java.version");
    if (version.startsWith("1.")) {
      version = version.substring(2, 3);
    } else {
      int dot = version.indexOf(".");
      if (dot != -1) {
        version = version.substring(0, dot);
      }
      int dash = version.indexOf("-");
      if (dash != -1) {
        version = version.substring(0, dash);
      }
    }
    return Integer.parseInt(version);
  }

  @Test
  public void background() throws Exception {
    // TODO: Only enable background tests for < 17
    if (getJavaVersion() < 17) {
      backgroundTest("BackgroundSnoop");
    }
  }

  @Test
  public void typedBackground() throws Exception {
    // TODO: Only enable background tests for < 17
    if (getJavaVersion() < 17) {
      backgroundTest("TypedBackgroundSnoop");
    }
  }

  @Test
  public void typedFunction() throws Exception {
    URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json");
    assertThat(resourceUrl).isNotNull();
    String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8);
    testFunction(
        SignatureType.TYPED,
        fullTarget("Typed"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder()
                .setRequestText(originalJson)
                .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}")
                .build()),
        Collections.emptyMap());
  }

  @Test
  public void typedVoidFunction() throws Exception {
    testFunction(
        SignatureType.TYPED,
        fullTarget("TypedVoid"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder().setRequestText("{}").setExpectedResponseCode(204).build()),
        Collections.emptyMap());
  }

  @Test
  public void typedCustomFormat() throws Exception {
    testFunction(
        SignatureType.TYPED,
        fullTarget("TypedCustomFormat"),
        ImmutableList.of(),
        ImmutableList.of(
            TestCase.builder()
                .setRequestText("abc\n123\n$#@\n")
                .setExpectedResponseText("abc123$#@")
                .setExpectedResponseCode(200)
                .build()),
        Collections.emptyMap());
  }

  private void backgroundTest(String target) throws Exception {
    File snoopFile = snoopFile();
    String gcfRequestText = sampleLegacyEvent(snoopFile);
    JsonObject expectedJson = new Gson().fromJson(gcfRequestText, JsonObject.class);
    TestCase gcfTestCase =
        TestCase.builder()
            .setRequestText(gcfRequestText)
            .setSnoopFile(snoopFile)
            .setExpectedJson(expectedJson)
            .build();

    // A CloudEvent using the "structured content mode", where both the metadata and the payload
    // are in the body of the HTTP request.
    EventFormat jsonFormat =
        EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
    String cloudEventRequestText =
        new String(jsonFormat.serialize(sampleCloudEvent(snoopFile)), UTF_8);
    // For CloudEvents, we don't currently populate Context#getResource with anything interesting,
    // so we excise that from the expected text we would have with legacy events.
    JsonObject cloudEventExpectedJson = new Gson().fromJson(gcfRequestText, JsonObject.class);
    TestCase cloudEventsStructuredTestCase =
        TestCase.builder()
            .setSnoopFile(snoopFile)
            .setRequestText(cloudEventRequestText)
            .setHttpContentType("application/cloudevents+json; charset=utf-8")
            .setExpectedJson(cloudEventExpectedJson)
            .build();

    // A CloudEvent using the "binary content mode", where the metadata is in HTTP headers and the
    // payload is the body of the HTTP request.
    Map<String, String> headers = new TreeMap<>();
    AtomicReference<byte[]> bodyRef = new AtomicReference<>();
    HttpMessageFactory.createWriter(headers::put, bodyRef::set)
        .writeBinary(sampleCloudEvent(snoopFile));
    TestCase cloudEventsBinaryTestCase =
        TestCase.builder()
            .setSnoopFile(snoopFile)
            .setRequestText(new String(bodyRef.get(), UTF_8))
            .setHttpContentType(headers.get("Content-Type"))
            .setHttpHeaders(ImmutableMap.copyOf(headers))
            .setExpectedJson(cloudEventExpectedJson)
            .build();

    backgroundTest(
        SignatureType.BACKGROUND,
        fullTarget(target),
        ImmutableList.of(gcfTestCase, cloudEventsStructuredTestCase, cloudEventsBinaryTestCase));
  }

  /**
   * Tests a CloudEvent being handled by a CloudEvent handler (no translation to or from legacy).
   */
  @Test
  public void nativeCloudEvent() throws Exception {
    File snoopFile = snoopFile();
    CloudEvent cloudEvent = sampleCloudEvent(snoopFile);
    EventFormat jsonFormat =
        EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
    String cloudEventJson = new String(jsonFormat.serialize(cloudEvent), UTF_8);

    // A CloudEvent using the "structured content mode", where both the metadata and the payload
    // are in the body of the HTTP request.
    JsonObject cloudEventJsonObject = new Gson().fromJson(cloudEventJson, JsonObject.class);
    TestCase cloudEventsStructuredTestCase =
        TestCase.builder()
            .setSnoopFile(snoopFile)
            .setRequestText(cloudEventJson)
            .setHttpContentType("application/cloudevents+json; charset=utf-8")
            .setExpectedJson(cloudEventJsonObject)
            .build();

    // A CloudEvent using the "binary content mode", where the metadata is in HTTP headers and the
    // payload is the body of the HTTP request.
    Map<String, String> headers = new TreeMap<>();
    AtomicReference<byte[]> bodyRef = new AtomicReference<>();
    HttpMessageFactory.createWriter(headers::put, bodyRef::set)
        .writeBinary(sampleCloudEvent(snoopFile));
    TestCase cloudEventsBinaryTestCase =
        TestCase.builder()
            .setSnoopFile(snoopFile)
            .setRequestText(new String(bodyRef.get(), UTF_8))
            .setHttpContentType(headers.get("Content-Type"))
            .setHttpHeaders(ImmutableMap.copyOf(headers))
            .setExpectedJson(cloudEventJsonObject)
            .build();

    backgroundTest(
        SignatureType.CLOUD_EVENT,
        fullTarget("CloudEventSnoop"),
        ImmutableList.of(cloudEventsStructuredTestCase, cloudEventsBinaryTestCase));
  }

  /** Tests a CloudEvent being handled by a CloudEvent handler throws exception */
  @Test
  public void nativeCloudEventException() throws Exception {
    String exceptionExpectedOutput =
        "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":"
            + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":"
            + " \"handle\"}, \"execution_id\": \""
            + EXECUTION_ID
            + "\", "
            + "\"message\": \"Failed to execute"
            + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n"
            + "java.lang.RuntimeException: exception thrown for test";
    File snoopFile = snoopFile();
    CloudEvent cloudEvent = sampleCloudEvent(snoopFile);
    EventFormat jsonFormat =
        EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
    String cloudEventJson = new String(jsonFormat.serialize(cloudEvent), UTF_8);

    // A CloudEvent using the "structured content mode", where both the metadata and the payload
    // are in the body of the HTTP request.
    TestCase cloudEventsStructuredTestCase =
        TestCase.builder()
            .setRequestText(cloudEventJson)
            .setHttpContentType("application/cloudevents+json; charset=utf-8")
            .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID))
            .setExpectedResponseCode(500)
            .setExpectedOutput(exceptionExpectedOutput)
            .build();

    testFunction(
        SignatureType.CLOUD_EVENT,
        fullTarget("ExceptionBackground"),
        ImmutableList.of(),
        ImmutableList.of(cloudEventsStructuredTestCase),
        Collections.emptyMap());
  }

  @Test
  public void nested() throws Exception {
    String testText = "sic transit gloria mundi";
    testHttpFunction(
        fullTarget("Nested.Echo"),
        ImmutableList.of(
            TestCase.builder().setRequestText(testText).setExpectedResponseText(testText).build()));
  }

  @Test
  public void packageless() throws Exception {
    testHttpFunction(
        "PackagelessHelloWorld",
        ImmutableList.of(TestCase.builder().setExpectedResponseText("hello, world\n").build()));
  }

  @Test
  public void multipart() throws Exception {
    MultiPartRequestContent multiPartRequestContent = new MultiPartRequestContent();
    byte[] bytes = new byte[17];
    multiPartRequestContent.addPart(
        new ContentSourcePart(
            "bytes", null, HttpFields.EMPTY, new ByteBufferRequestContent(ByteBuffer.wrap(bytes))));
    multiPartRequestContent.addPart(
        new MultiPart.ContentSourcePart(
            "string", null, HttpFields.EMPTY, new StringRequestContent("1234567890")));
    multiPartRequestContent.close();

    String expectedResponse =
        "part bytes type application/octet-stream length 17\n"
            + "part string type text/plain;charset=UTF-8 length 10\n";
    testHttpFunction(
        fullTarget("Multipart"),
        ImmutableList.of(
            TestCase.builder()
                .setHttpContentType(multiPartRequestContent.getContentType())
                .setRequestContent(multiPartRequestContent)
                .setExpectedResponseText(expectedResponse)
                .build()));
  }

  private File snoopFile() throws IOException {
    return temporaryFolder.newFile(testName.getMethodName() + ".txt");
  }

  /** Any runtime class that user code shouldn't be able to see. */
  private static final Class<?> INTERNAL_CLASS = CloudFunctionsContext.class;

  private String functionJarString() throws IOException {
    Path functionJarTargetDir = Paths.get("../testfunction/target");
    Pattern functionJarPattern =
        Pattern.compile("java-function-invoker-testfunction-.*-tests\\.jar");
    List<Path> functionJars =
        Files.list(functionJarTargetDir)
            .map(path -> path.getFileName().toString())
            .filter(s -> functionJarPattern.matcher(s).matches())
            .map(s -> functionJarTargetDir.resolve(s))
            .collect(toList());
    assertWithMessage("Number of jars in %s matching %s", functionJarTargetDir, functionJarPattern)
        .that(functionJars)
        .hasSize(1);
    return Iterables.getOnlyElement(functionJars).toString();
  }

  /**
   * Tests that if we launch an HTTP function with {@code --classpath}, then the function code
   * cannot see the classes from the runtime. This is allows us to avoid conflicts between versions
   * of libraries that we use in the runtime and different versions of the same libraries that the
   * function might use.
   */
  @Test
  public void classpathOptionHttp() throws Exception {
    TestCase testCase =
        TestCase.builder()
            .setUrl("/?class=" + INTERNAL_CLASS.getName())
            .setExpectedResponseText("OK")
            .build();
    testFunction(
        SignatureType.HTTP,
        "com.example.functionjar.Foreground",
        ImmutableList.of("--classpath", functionJarString()),
        ImmutableList.of(testCase),
        Collections.emptyMap());
  }

  /** Like {@link #classpathOptionHttp} but for background functions. */
  @Test
  public void classpathOptionBackground() throws Exception {
    Gson gson = new Gson();
    URL resourceUrl = getClass().getResource("/adder_gcf_ga_event.json");
    assertThat(resourceUrl).isNotNull();
    String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8);
    JsonObject json = gson.fromJson(originalJson, JsonObject.class);
    JsonObject jsonData = json.getAsJsonObject("data");
    jsonData.addProperty("class", INTERNAL_CLASS.getName());
    testFunction(
        SignatureType.BACKGROUND,
        "com.example.functionjar.Background",
        ImmutableList.of("--classpath", functionJarString()),
        ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build()),
        Collections.emptyMap());
  }

  /** Like {@link #classpathOptionHttp} but for typed functions. */
  @Test
  public void classpathOptionTyped() throws Exception {
    URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json");
    assertThat(resourceUrl).isNotNull();
    String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8);
    testFunction(
        SignatureType.TYPED,
        "com.example.functionjar.Typed",
        ImmutableList.of("--classpath", functionJarString()),
        ImmutableList.of(
            TestCase.builder()
                .setRequestText(originalJson)
                .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}")
                .build()),
        Collections.emptyMap());
  }

  // In these tests, we test a number of different functions that express the same functionality
  // in different ways. Each function is invoked with a complete HTTP body that looks like a real
  // event. We start with a fixed body and insert into its JSON an extra property that tells the
  // function where to write what it received. We have to do this since background functions, by
  // design, don't return a value.
  private void backgroundTest(
      SignatureType signatureType, String functionTarget, List<TestCase> testCases)
      throws Exception {
    for (TestCase testCase : testCases) {
      File snoopFile = testCase.snoopFile().get();
      snoopFile.delete();
      testFunction(
          signatureType,
          functionTarget,
          ImmutableList.of(),
          ImmutableList.of(testCase),
          Collections.emptyMap());
      String snooped = new String(Files.readAllBytes(snoopFile.toPath()), StandardCharsets.UTF_8);
      Gson gson = new Gson();
      JsonObject snoopedJson = gson.fromJson(snooped, JsonObject.class);
      JsonObject expectedJson = testCase.expectedJson().get();
      expect
          .withMessage(
              "Testing %s with %s\nGOT %s\nNOT %s",
              functionTarget, testCase, snoopedJson, expectedJson)
          .that(snoopedJson)
          .isEqualTo(expectedJson);
    }
  }

  private void checkSnoopFile(TestCase testCase) throws IOException {
    File snoopFile = testCase.snoopFile().get();
    JsonObject expectedJson = testCase.expectedJson().get();
    String snooped = new String(Files.readAllBytes(snoopFile.toPath()), StandardCharsets.UTF_8);
    Gson gson = new Gson();
    JsonObject snoopedJson = gson.fromJson(snooped, JsonObject.class);
    expect.withMessage("Testing with %s", testCase).that(snoopedJson).isEqualTo(expectedJson);
  }

  private void testHttpFunction(String target, List<TestCase> testCases) throws Exception {
    testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases, Collections.emptyMap());
  }

  private void testFunction(
      SignatureType signatureType,
      String target,
      ImmutableList<String> extraArgs,
      List<TestCase> testCases,
      Map<String, String> environmentVariables)
      throws Exception {
    ServerProcess serverProcess =
        startServer(signatureType, target, extraArgs, environmentVariables);
    HttpClient httpClient = new HttpClient();
    try {
      httpClient.start();
      for (TestCase testCase : testCases) {
