Split up plugin fields tests into extension/REST/SSH files

The unified test class was getting unwieldy and difficult to read. This
way we can also disable HTTP and SSH on the extension API tests, which
further proves that moving bindings into the sys injector (I3fd7de21)
had the desired effect: we can use plugin-provided fields even when
there is no HTTP or SSH server.

These tests should also be moved around into separate packages since
they are not all "api" tests; that will be a later cleanup.

Change-Id: I9a8a48a8106442a8144d64b862b692a46f5a9482
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbstractPluginFieldsTest.java b/javatests/com/google/gerrit/acceptance/api/change/AbstractPluginFieldsTest.java
new file mode 100644
index 0000000..8ecafa1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbstractPluginFieldsTest.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// 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.gerrit.acceptance.api.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.List;
+import java.util.Objects;
+import org.junit.Ignore;
+import org.kohsuke.args4j.Option;
+
+@Ignore
+public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  protected static class MyInfo extends PluginDefinedInfo {
+    @Nullable String theAttribute;
+
+    MyInfo(@Nullable String theAttribute) {
+      this.theAttribute = theAttribute;
+    }
+
+    MyInfo(String name, @Nullable String theAttribute) {
+      this.name = requireNonNull(name);
+      this.theAttribute = theAttribute;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof MyInfo)) {
+        return false;
+      }
+      MyInfo i = (MyInfo) o;
+      return Objects.equals(name, i.name) && Objects.equals(theAttribute, i.theAttribute);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(name, theAttribute);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("name", name)
+          .add("theAttribute", theAttribute)
+          .toString();
+    }
+  }
+
+  protected static class NullAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
+    }
+  }
+
+  protected static class SimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
+  private static class MyOptions implements DynamicBean {
+    @Option(name = "--opt")
+    private String opt;
+  }
+
+  protected static class OptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance(
+              (cd, bp, p) -> {
+                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
+                return opts != null ? new MyInfo("opt " + opts.opt) : null;
+              });
+      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+    }
+  }
+
+  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
+      assertThat(getter.call(id)).isNull();
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
+    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
+  }
+
+  protected void getChangeWithSimpleAttribute(
+      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
+      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithOption(
+      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id)).isNull();
+  }
+
+  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+    assertThat(changeInfos).hasSize(1);
+    return pluginInfoFromChangeInfo(changeInfos.get(0));
+  }
+
+  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+    List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
+    if (pluginInfo == null) {
+      return null;
+    }
+    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetter {
+    List<MyInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetterWithOptions {
+    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+        throws Exception;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 9279488..37939b0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -1,11 +1,27 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
+PLUGIN_UTIL_SRCS = ["AbstractPluginFieldsTest.java"]
+
 acceptance_tests(
-    srcs = glob(["*IT.java"]),
+    srcs = glob(
+        ["*IT.java"],
+        exclude = PLUGIN_UTIL_SRCS,
+    ),
     group = "api_change",
     labels = [
         "api",
         "noci",
     ],
-    deps = ["//java/com/google/gerrit/server/util/time"],
+    deps = [
+        ":plugin_util",
+        "//java/com/google/gerrit/server/util/time",
+    ],
+)
+
+java_library(
+    name = "plugin_util",
+    srcs = PLUGIN_UTIL_SRCS,
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index d65cf0b..c68ceac 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -14,127 +14,15 @@
 
 package com.google.gerrit.acceptance.api.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
-
-import com.google.common.base.Joiner;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.io.CharStreams;
-import com.google.common.reflect.TypeToken;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
-import com.google.gerrit.server.query.change.OutputStreamQuery;
-import com.google.gerrit.server.restapi.change.GetChange;
-import com.google.gerrit.server.restapi.change.QueryChanges;
-import com.google.gerrit.sshd.commands.Query;
-import com.google.gson.Gson;
 import com.google.inject.AbstractModule;
-import com.google.inject.Module;
-import java.io.StringReader;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.stream.Stream;
 import org.junit.Test;
-import org.kohsuke.args4j.Option;
 
-@UseSsh
-public class PluginFieldsIT extends AbstractDaemonTest {
-  private static final Gson REST_GSON = OutputFormat.JSON.newGson();
-  private static final Gson SSH_GSON = OutputStreamQuery.GSON;
-
-  static class MyInfo extends PluginDefinedInfo {
-    @Nullable String theAttribute;
-
-    MyInfo(@Nullable String theAttribute) {
-      this.theAttribute = theAttribute;
-    }
-
-    MyInfo(String name, @Nullable String theAttribute) {
-      this.name = requireNonNull(name);
-      this.theAttribute = theAttribute;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (!(o instanceof MyInfo)) {
-        return false;
-      }
-      MyInfo i = (MyInfo) o;
-      return Objects.equals(name, i.name) && Objects.equals(theAttribute, i.theAttribute);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(name, theAttribute);
-    }
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("name", name)
-          .add("theAttribute", theAttribute)
-          .toString();
-    }
-  }
-
-  static class NullAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
-    }
-  }
-
-  static class SimpleAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("simple"))
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  static class MyOptions implements DynamicBean {
-    @Option(name = "--opt")
-    private String opt;
-  }
-
-  static class OptionAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance(
-              (cd, bp, p) -> {
-                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
-                return opts != null ? new MyInfo("opt " + opts.opt) : null;
-              });
-      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
-    }
-  }
+@NoHttpd
+public class PluginFieldsIT extends AbstractPluginFieldsTest {
+  // No tests for /detail via the extension API, since the extension API doesn't have that method.
 
   @Test
   public void queryChangeApiWithNullAttribute() throws Exception {
@@ -143,114 +31,24 @@
   }
 
   @Test
-  public void queryChangeRestWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void queryChangeSshWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void getChangeApiWithNullAttribute() throws Exception {
     getChangeWithNullAttribute(
         id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
   }
 
   @Test
-  public void getChangeRestWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailRestWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  // No tests for /detail via the extension API, since the extension API doesn't have that method.
-  // No tests for getting a single change over SSH, since the only API is the query API.
-
-  private void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
-      assertThat(getter.call(id)).isNull();
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  @Test
   public void queryChangeApiWithSimpleAttribute() throws Exception {
     getChangeWithSimpleAttribute(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
   }
 
   @Test
-  public void queryChangeRestWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void queryChangeSshWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void getChangeApiWithSimpleAttribute() throws Exception {
     getChangeWithSimpleAttribute(
         id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
   }
 
   @Test
-  public void getChangeRestWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailRestWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  // No tests for getting a single change over SSH, since the only API is the query API.
-
-  @Test
-  public void getChangeApiWithSimpleAttributeWithExplicitExport() throws Exception {
-    // For backwards compatibility with old plugins, allow modules to bind into the
-    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
-    // this test to prove that the mapping works.
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
-        SimpleAttributeWithExplicitExportModule.class);
-  }
-
-  private void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
-    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
-  }
-
-  private void getChangeWithSimpleAttribute(
-      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
-      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  @Test
   public void queryChangeApiWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
@@ -260,194 +58,28 @@
   }
 
   @Test
-  public void queryChangeSshWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))),
-        (id, opts) ->
-            pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id, opts))));
-  }
-
-  @Test
-  public void queryChangeRestWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))),
-        (id, opts) ->
-            pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeRestWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id, opts))));
-  }
-
-  @Test
   public void getChangeApiWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
         (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
   }
 
+  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeAttributeFactory.class)
+          .annotatedWith(Exports.named("simple"))
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
   @Test
-  public void getChangeDetailRestWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))),
-        (id, opts) ->
-            pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id, opts))));
-  }
-
-  // No tests for /detail via the extension API, since the extension API doesn't have that method.
-  // No tests for getting a single change over SSH, since the only API is the query API.
-
-  private void getChangeWithOption(
-      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
-      throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getterWithoutOptions.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
-      assertThat(getterWithoutOptions.call(id))
-          .containsExactly(new MyInfo("my-plugin", "opt null"));
-      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
-          .containsExactly(new MyInfo("my-plugin", "opt foo"));
-    }
-
-    assertThat(getterWithoutOptions.call(id)).isNull();
-  }
-
-  private String changeQueryUrl(Change.Id id) {
-    return changeQueryUrl(id, ImmutableListMultimap.of());
-  }
-
-  private String changeQueryUrl(Change.Id id, ImmutableListMultimap<String, String> opts) {
-    String url = "/changes/?q=" + id;
-    String queryString = buildQueryString(opts);
-    if (!queryString.isEmpty()) {
-      url += "&" + queryString;
-    }
-    return url;
-  }
-
-  private String changeQueryCmd(Change.Id id) {
-    return changeQueryCmd(id, ImmutableListMultimap.of());
-  }
-
-  private String changeQueryCmd(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
-    return "gerrit query --format json "
-        + pluginOptions.entries().stream()
-            .flatMap(e -> Stream.of("--" + e.getKey(), e.getValue()))
-            .collect(joining(" "))
-        + " "
-        + id;
-  }
-
-  private String changeUrl(Change.Id id) {
-    return changeUrl(id, ImmutableListMultimap.of());
-  }
-
-  private String changeUrl(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
-    return changeUrl(id, "", pluginOptions);
-  }
-
-  private String changeDetailUrl(Change.Id id) {
-    return changeDetailUrl(id, ImmutableListMultimap.of());
-  }
-
-  private String changeDetailUrl(
-      Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
-    return changeUrl(id, "/detail", pluginOptions);
-  }
-
-  private String changeUrl(
-      Change.Id id, String suffix, ImmutableListMultimap<String, String> pluginOptions) {
-    String url = "/changes/" + project + "~" + id + suffix;
-    String queryString = buildQueryString(pluginOptions);
-    if (!queryString.isEmpty()) {
-      url += "?" + queryString;
-    }
-    return url;
-  }
-
-  private static String buildQueryString(ImmutableListMultimap<String, String> opts) {
-    return Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
-  }
-
-  private static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
-    assertThat(changeInfos).hasSize(1);
-    return pluginInfoFromChangeInfo(changeInfos.get(0));
-  }
-
-  private static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
-    List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
-    if (pluginInfo == null) {
-      return null;
-    }
-    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
-  }
-
-  @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonListRest(RestResponse res) throws Exception {
-    res.assertOK();
-
-    // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
-    // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
-    List<Map<String, Object>> changeInfos =
-        REST_GSON.fromJson(
-            res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
-    assertThat(changeInfos).hasSize(1);
-    return myInfo(changeInfos.get(0));
-  }
-
-  @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonListSsh(String sshOutput) throws Exception {
-    List<Map<String, Object>> changeAttrs = new ArrayList<>();
-    for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
-      // Don't deserialize to ChangeAttribute directly, since that would treat the plugins field as
-      // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
-      Map<String, Object> changeAttr =
-          SSH_GSON.fromJson(line, new TypeToken<Map<String, Object>>() {}.getType());
-      if (!"stats".equals(changeAttr.get("type"))) {
-        changeAttrs.add(changeAttr);
-      }
-    }
-
-    assertThat(changeAttrs).hasSize(1);
-
-    Object plugins = changeAttrs.get(0).get("plugins");
-    if (plugins == null) {
-      return null;
-    }
-    return SSH_GSON.fromJson(SSH_GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
-  }
-
-  @Nullable
-  private List<MyInfo> pluginInfoFromChangeInfoRest(RestResponse res) throws Exception {
-    res.assertOK();
-
-    // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
-    // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
-    return myInfo(
-        REST_GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType()));
-  }
-
-  private static List<MyInfo> myInfo(Map<String, Object> changeInfo) {
-    Object plugins = changeInfo.get("plugins");
-    if (plugins == null) {
-      return null;
-    }
-    return REST_GSON.fromJson(
-        REST_GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
-  }
-
-  @FunctionalInterface
-  private interface PluginInfoGetter {
-    List<MyInfo> call(Change.Id id) throws Exception;
-  }
-
-  @FunctionalInterface
-  private interface PluginInfoGetterWithOptions {
-    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
-        throws Exception;
+  public void getChangeApiWithSimpleAttributeWithExplicitExport() throws Exception {
+    // For backwards compatibility with old plugins, allow modules to bind into the
+    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
+    // this test to prove that the mapping works.
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
+        SimpleAttributeWithExplicitExportModule.class);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsRestIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsRestIT.java
new file mode 100644
index 0000000..54ff975
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsRestIT.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// 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.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class PluginFieldsRestIT extends AbstractPluginFieldsTest {
+  private static final Gson GSON = OutputFormat.JSON.newGson();
+
+  @Test
+  public void queryChangeRestWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeRestWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeRestWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeRestWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeRestWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) ->
+            pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeRestWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) ->
+            pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  private String changeQueryUrl(Change.Id id) {
+    return changeQueryUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryUrl(Change.Id id, ImmutableListMultimap<String, String> opts) {
+    String url = "/changes/?q=" + id;
+    String queryString = buildQueryString(opts);
+    if (!queryString.isEmpty()) {
+      url += "&" + queryString;
+    }
+    return url;
+  }
+
+  private String changeUrl(Change.Id id) {
+    return changeUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeUrl(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "", pluginOptions);
+  }
+
+  private String changeDetailUrl(Change.Id id) {
+    return changeDetailUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeDetailUrl(
+      Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "/detail", pluginOptions);
+  }
+
+  private String changeUrl(
+      Change.Id id, String suffix, ImmutableListMultimap<String, String> pluginOptions) {
+    String url = "/changes/" + project + "~" + id + suffix;
+    String queryString = buildQueryString(pluginOptions);
+    if (!queryString.isEmpty()) {
+      url += "?" + queryString;
+    }
+    return url;
+  }
+
+  private static String buildQueryString(ImmutableListMultimap<String, String> opts) {
+    return Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonListRest(RestResponse res) throws Exception {
+    res.assertOK();
+
+    // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
+    // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    assertThat(changeInfos).hasSize(1);
+    return myInfo(changeInfos.get(0));
+  }
+
+  @Nullable
+  private List<MyInfo> pluginInfoFromChangeInfoRest(RestResponse res) throws Exception {
+    res.assertOK();
+
+    // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
+    // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
+    return myInfo(
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType()));
+  }
+
+  private static List<MyInfo> myInfo(Map<String, Object> changeInfo) {
+    Object plugins = changeInfo.get("plugins");
+    if (plugins == null) {
+      return null;
+    }
+    return GSON.fromJson(GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsSshIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsSshIT.java
new file mode 100644
index 0000000..df896a8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsSshIT.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// 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.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.change.OutputStreamQuery;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+@UseSsh
+public class PluginFieldsSshIT extends AbstractPluginFieldsTest {
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
+  private static final Gson GSON = OutputStreamQuery.GSON;
+
+  @Test
+  public void queryChangeSshWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeSshWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeSshWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) ->
+            pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  private String changeQueryCmd(Change.Id id) {
+    return changeQueryCmd(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryCmd(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return "gerrit query --format json "
+        + pluginOptions.entries().stream()
+            .flatMap(e -> Stream.of("--" + e.getKey(), e.getValue()))
+            .collect(joining(" "))
+        + " "
+        + id;
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonListSsh(String sshOutput) throws Exception {
+    List<Map<String, Object>> changeAttrs = new ArrayList<>();
+    for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
+      // Don't deserialize to ChangeAttribute directly, since that would treat the plugins field as
+      // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
+      Map<String, Object> changeAttr =
+          GSON.fromJson(line, new TypeToken<Map<String, Object>>() {}.getType());
+      if (!"stats".equals(changeAttr.get("type"))) {
+        changeAttrs.add(changeAttr);
+      }
+    }
+
+    assertThat(changeAttrs).hasSize(1);
+
+    Object plugins = changeAttrs.get(0).get("plugins");
+    if (plugins == null) {
+      return null;
+    }
+    return GSON.fromJson(GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+  }
+}