Add mock-based unit test for NodeHealth cloud-mode logic (#4703)
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthSolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthSolrCloudTest.java
index 61ab10b..1cf7da0 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthSolrCloudTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthSolrCloudTest.java
@@ -18,22 +18,18 @@
 package org.apache.solr.handler.admin.api;
 
 import static org.apache.solr.client.api.model.NodeHealthResponse.NodeStatus.OK;
-import static org.hamcrest.Matchers.containsString;
 
-import java.util.concurrent.TimeUnit;
-import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.request.CollectionAdminRequest;
 import org.apache.solr.client.solrj.request.NodeApi;
 import org.apache.solr.cloud.SolrCloudTestCase;
-import org.apache.solr.common.SolrException;
-import org.apache.solr.common.SolrException.ErrorCode;
-import org.apache.solr.embedded.JettySolrRunner;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
 /**
- * Tests for the node-health API, on SolrCloud clusters
+ * Tests for the node-health API, on SolrCloud clusters. Failure scenarios (ZK connection loss,
+ * missing live node, unhealthy cores) are covered by the mock-based {@link NodeHealthTest}.
  *
+ * @see NodeHealthTest
  * @see NodeHealthStandaloneTest
  */
 public class NodeHealthSolrCloudTest extends SolrCloudTestCase {
@@ -66,64 +62,4 @@
     assertEquals(OK, response.status);
     assertEquals("All cores are healthy", response.message);
   }
-
-  @Test
-  public void testCloudMode_UnhealthyWhenZkClientClosed() throws Exception {
-    // Use a fresh node so closing its ZK client does not break the primary cluster node
-    JettySolrRunner newJetty = cluster.startJettySolrRunner();
-    cluster.waitForNode(newJetty, 30);
-    try (SolrClient nodeClient = newJetty.newClient()) {
-      // Sanity check: the new node should start out healthy
-      assertEquals(OK, new NodeApi.Healthcheck().process(nodeClient).status);
-
-      // Break the ZK connection to put the node into an unhealthy state
-      newJetty.getCoreContainer().getZkController().getZkClient().close();
-
-      SolrException e =
-          assertThrows(SolrException.class, () -> new NodeApi.Healthcheck().process(nodeClient));
-      assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
-      assertThat(e.getMessage(), containsString(("Host Unavailable")));
-    } finally {
-      newJetty.stop();
-    }
-  }
-
-  /**
-   * Verifies that when the node's name is absent from ZooKeeper's live-nodes set (while the ZK
-   * session itself is still connected), the v2 health-check API throws a {@code
-   * SERVICE_UNAVAILABLE} exception with a message identifying the live-nodes check as the cause.
-   *
-   * <p>This specifically exercises the code path at NodeHealth#getClusterState() that checks {@code
-   * clusterState.getLiveNodes().contains(nodeName)}.
-   */
-  @Test
-  public void testNotInLiveNodes_ThrowsServiceUnavailable() throws Exception {
-    JettySolrRunner newJetty = cluster.startJettySolrRunner();
-    cluster.waitForNode(newJetty, 30);
-    try (SolrClient nodeClient = newJetty.newClient()) {
-      // Sanity check: the new node should start out healthy
-      assertEquals(OK, new NodeApi.Healthcheck().process(nodeClient).status);
-
-      String nodeName = newJetty.getCoreContainer().getZkController().getNodeName();
-
-      // Remove the node from ZooKeeper's live_nodes without closing the ZK session.
-      // This ensures the "ZK not connected" check passes and only the "not in live nodes"
-      // check fires, isolating the code path under test.
-      newJetty.getCoreContainer().getZkController().removeEphemeralLiveNode();
-
-      // Wait for the node's own ZkStateReader to reflect the removal before querying it.
-      newJetty
-          .getCoreContainer()
-          .getZkController()
-          .getZkStateReader()
-          .waitForLiveNodes(10, TimeUnit.SECONDS, missingLiveNode(nodeName));
-
-      SolrException e =
-          assertThrows(SolrException.class, () -> new NodeApi.Healthcheck().process(nodeClient));
-      assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
-      assertThat(e.getMessage(), containsString("Not in live nodes"));
-    } finally {
-      newJetty.stop();
-    }
-  }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthTest.java
new file mode 100644
index 0000000..0b8080c
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeHealthTest.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.handler.admin.api;
+
+import static org.apache.solr.client.api.model.NodeHealthResponse.NodeStatus.OK;
+import static org.hamcrest.Matchers.containsString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.cloud.CloudDescriptor;
+import org.apache.solr.cloud.ClusterStateMockUtil;
+import org.apache.solr.cloud.ZkController;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrException.ErrorCode;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.Replica;
+import org.apache.solr.common.cloud.SolrZkClient;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.CoreDescriptor;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Unit test for the cloud-mode logic of {@link NodeHealth}, using mocks instead of a real cluster.
+ *
+ * @see NodeHealthSolrCloudTest
+ * @see NodeHealthStandaloneTest
+ */
+public class NodeHealthTest extends SolrTestCase {
+
+  private static final String NODE_NAME = "baseUrl1:8983_";
+
+  private CoreContainer mockCoreContainer;
+  private ZkController mockZkController;
+  private ZkStateReader mockZkStateReader;
+  private SolrZkClient mockZkClient;
+  private NodeHealth nodeHealth;
+
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    SolrTestCaseJ4.assumeWorkingMockito();
+  }
+
+  @Before
+  public void setupMocks() {
+    mockCoreContainer = mock(CoreContainer.class);
+    mockZkController = mock(ZkController.class);
+    mockZkStateReader = mock(ZkStateReader.class);
+    mockZkClient = mock(SolrZkClient.class);
+
+    when(mockCoreContainer.isZooKeeperAware()).thenReturn(true);
+    when(mockCoreContainer.getZkController()).thenReturn(mockZkController);
+    when(mockZkController.getZkStateReader()).thenReturn(mockZkStateReader);
+    when(mockZkController.getNodeName()).thenReturn(NODE_NAME);
+    when(mockZkStateReader.getZkClient()).thenReturn(mockZkClient);
+    when(mockZkClient.isConnected()).thenReturn(true);
+    when(mockZkClient.isClosed()).thenReturn(false);
+    when(mockZkStateReader.getClusterState())
+        .thenReturn(new ClusterState(Set.of(NODE_NAME), Map.of()));
+
+    nodeHealth = new NodeHealth(mockCoreContainer);
+  }
+
+  @Test
+  public void testUninitializedCoreContainerReturns500() {
+    SolrException e =
+        expectThrows(SolrException.class, () -> new NodeHealth(null).healthcheck(null, null));
+    assertEquals(ErrorCode.SERVER_ERROR.code, e.code());
+
+    when(mockCoreContainer.isShutDown()).thenReturn(true);
+    e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(null, null));
+    assertEquals(ErrorCode.SERVER_ERROR.code, e.code());
+  }
+
+  @Test
+  public void testHealthyNodeReturnsOkStatus() {
+    final var response = nodeHealth.healthcheck(null, null);
+
+    assertEquals(OK, response.status);
+    assertNull("Expected no error on a healthy node", response.error);
+  }
+
+  @Test
+  public void testZkClientClosedReturns503() {
+    when(mockZkClient.isClosed()).thenReturn(true);
+
+    SolrException e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(null, null));
+    assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
+    assertThat(e.getMessage(), containsString("Not connected to zk"));
+  }
+
+  @Test
+  public void testZkClientDisconnectedReturns503() {
+    when(mockZkClient.isConnected()).thenReturn(false);
+
+    SolrException e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(null, null));
+    assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
+    assertThat(e.getMessage(), containsString("Not connected to zk"));
+  }
+
+  @Test
+  public void testNotInLiveNodesReturns503() {
+    when(mockZkStateReader.getClusterState())
+        .thenReturn(new ClusterState(Set.of("someOtherNode:8983_"), Map.of()));
+
+    SolrException e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(null, null));
+    assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
+    assertThat(e.getMessage(), containsString("Not in live nodes"));
+  }
+
+  @Test
+  public void testCoreLoadingNotCompleteReturns503() {
+    when(mockCoreContainer.isStatusLoadComplete()).thenReturn(false);
+
+    SolrException e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(true, null));
+    assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
+    assertThat(e.getMessage(), containsString("Core Loading not complete"));
+  }
+
+  @Test
+  public void testRequireHealthyCoresReturnsOkWhenAllCoresHealthy() {
+    when(mockCoreContainer.isStatusLoadComplete()).thenReturn(true);
+    CoreDescriptor activeCore = mockCoreDescriptor(Replica.State.ACTIVE);
+    when(mockCoreContainer.getCoreDescriptors()).thenReturn(List.of(activeCore));
+
+    final var response = nodeHealth.healthcheck(true, null);
+
+    assertEquals(OK, response.status);
+    assertEquals("All cores are healthy", response.message);
+  }
+
+  @Test
+  public void testUnhealthyCoresReturns503() {
+    when(mockCoreContainer.isStatusLoadComplete()).thenReturn(true);
+    CoreDescriptor recoveringCore = mockCoreDescriptor(Replica.State.RECOVERING);
+    when(mockCoreContainer.getCoreDescriptors()).thenReturn(List.of(recoveringCore));
+    when(mockCoreContainer.getNumAllCores()).thenReturn(1);
+
+    SolrException e = expectThrows(SolrException.class, () -> nodeHealth.healthcheck(true, null));
+    assertEquals(ErrorCode.SERVICE_UNAVAILABLE.code, e.code());
+    assertThat(
+        e.getMessage(),
+        containsString("1 out of 1 replicas are currently initializing or recovering"));
+  }
+
+  /**
+   * Creates a core descriptor for a core of collection1/slice1 in the given state, and points the
+   * mocked cluster state at a matching collection.
+   */
+  private CoreDescriptor mockCoreDescriptor(Replica.State state) {
+    CoreDescriptor coreDescriptor =
+        new CoreDescriptor(
+            "slice1_replica1",
+            createTempDir(),
+            Map.of(
+                CoreDescriptor.CORE_SHARD, "slice1",
+                CoreDescriptor.CORE_COLLECTION, "collection1",
+                CoreDescriptor.CORE_NODE_NAME, "slice1_replica1"),
+            new Properties(),
+            mockZkController);
+    CloudDescriptor cloudDescriptor = coreDescriptor.getCloudDescriptor();
+    cloudDescriptor.setHasRegistered(true);
+    cloudDescriptor.setLastPublished(state);
+
+    // collection1 with slice1 holding one active replica, on our (live) node
+    try (ZkStateReader stateReader = ClusterStateMockUtil.buildClusterState("csr", NODE_NAME)) {
+      when(mockZkStateReader.getClusterState()).thenReturn(stateReader.getClusterState());
+    }
+
+    return coreDescriptor;
+  }
+}