From d60374fa92f07896da2c96bcc48a62b59f648473 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 21 Jun 2021 16:40:05 +0200
Subject: [PATCH] Handled polling of moveNode operations

---
 .../ia2/vospace/ui/client/VOSpaceClient.java  | 16 +++++
 .../ui/controller/NodesController.java        | 31 +++++++-
 .../vospace/ui/client/VOSpaceClientTest.java  | 10 +++
 .../ui/controller/NodesControllerTest.java    | 72 +++++++++++++++++++
 vospace-ui-frontend/src/store.js              | 13 ++--
 5 files changed, 136 insertions(+), 6 deletions(-)

diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
index b1088d3..819a285 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
@@ -35,6 +35,7 @@ import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpSession;
 import javax.xml.bind.JAXB;
+import net.ivoa.xml.uws.v1.ExecutionPhase;
 import net.ivoa.xml.uws.v1.JobSummary;
 import net.ivoa.xml.uws.v1.Jobs;
 import net.ivoa.xml.vospace.v2.Node;
@@ -189,6 +190,21 @@ public class VOSpaceClient {
         });
     }
 
+    public ExecutionPhase getJobPhase(String jobId) {
+
+        HttpRequest request = getRequest("/transfers/" + jobId + "/phase")
+                .GET()
+                .build();
+
+        return call(request, BodyHandlers.ofInputStream(), 200, res -> {
+            try {
+                return ExecutionPhase.valueOf(new String(res.readAllBytes()));
+            } catch (IOException ex) {
+                throw new UncheckedIOException(ex);
+            }
+        });
+    }
+
     public String getErrorDetail(String jobId) {
 
         HttpRequest request = getRequest("/transfers/" + jobId + "/error")
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
index 0b44715..fe5ffa6 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
@@ -7,7 +7,9 @@ package it.inaf.ia2.vospace.ui.controller;
 
 import it.inaf.ia2.aa.data.User;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
+import it.inaf.ia2.vospace.ui.data.Job;
 import it.inaf.ia2.vospace.ui.data.ListNodeData;
+import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
 import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator;
 import it.inaf.ia2.vospace.ui.service.MoveNodeModalHtmlGenerator;
 import it.inaf.oats.vospace.datamodel.NodeUtils;
@@ -18,6 +20,7 @@ import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
+import net.ivoa.xml.uws.v1.ExecutionPhase;
 import net.ivoa.xml.uws.v1.JobSummary;
 import net.ivoa.xml.vospace.v2.ContainerNode;
 import net.ivoa.xml.vospace.v2.Node;
@@ -46,6 +49,9 @@ public class NodesController extends BaseController {
     @Value("${vospace-authority}")
     private String authority;
 
+    @Value("${maxPollingAttempts:10}")
+    private int maxPollingAttempts;
+    
     @Autowired
     private VOSpaceClient client;
 
@@ -154,7 +160,7 @@ public class NodesController extends BaseController {
     }
 
     @PostMapping(value = "/move")
-    public void moveNode(@RequestBody Map<String, Object> params) {
+    public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) {
 
         String target = getRequiredParam(params, "target");
         String direction = getRequiredParam(params, "direction");
@@ -165,7 +171,28 @@ public class NodesController extends BaseController {
 
         JobSummary job = client.startTransferJob(transfer);
 
-        // TODO: polling
+        // Try to perform polling until completion. If it takes too much time 
+        // sends the execution phase to the UI and let it handles the polling.
+        ExecutionPhase phase;
+        int i = 0;
+        do {
+            phase = client.getJobPhase(job.getJobId());
+            if (phase == ExecutionPhase.COMPLETED) {
+                break;
+            } else if (phase == ExecutionPhase.ERROR) {
+                String errorDetail = client.getErrorDetail(job.getJobId());
+                throw new VOSpaceException("MoveNode operation failed: " + errorDetail);
+            }
+            try {
+                Thread.sleep(1000);
+            } catch (InterruptedException ex) {
+            }
+            i++;
+        } while (i < maxPollingAttempts);
+
+        job.setPhase(phase);
+
+        return ResponseEntity.ok(new Job(job));
     }
 
     protected String getPath(String prefix) {
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java
index 0a3a110..a816220 100644
--- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java
@@ -18,6 +18,7 @@ import java.util.Arrays;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import javax.servlet.http.HttpServletRequest;
+import net.ivoa.xml.uws.v1.ExecutionPhase;
 import net.ivoa.xml.vospace.v2.ContainerNode;
 import net.ivoa.xml.vospace.v2.Protocol;
 import net.ivoa.xml.vospace.v2.Transfer;
@@ -186,6 +187,15 @@ public class VOSpaceClientTest {
         assertEquals("Protocol negotiation failed", ex.getMessage());
     }
 
+    @Test
+    public void testGetJobPhase() {
+
+        CompletableFuture response = getMockedStreamResponseFuture(200, "COMPLETED");
+        when(mockedHttpClient.sendAsync(any(), any())).thenReturn(response);
+
+        assertEquals(ExecutionPhase.COMPLETED, voSpaceClient.getJobPhase("job_id"));
+    }
+
     protected static String getResourceFileContent(String fileName) {
         try ( InputStream in = VOSpaceClientTest.class.getClassLoader().getResourceAsStream(fileName)) {
             return new String(in.readAllBytes(), StandardCharsets.UTF_8);
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
index 75571a3..5879848 100644
--- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
@@ -7,12 +7,19 @@ package it.inaf.ia2.vospace.ui.controller;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
+import it.inaf.ia2.vospace.ui.data.Job;
+import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import net.ivoa.xml.uws.v1.ExecutionPhase;
+import net.ivoa.xml.uws.v1.JobSummary;
 import net.ivoa.xml.vospace.v2.ContainerNode;
 import net.ivoa.xml.vospace.v2.DataNode;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 import org.junit.jupiter.api.Test;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -25,7 +32,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
 import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -33,6 +42,7 @@ import org.springframework.web.util.NestedServletException;
 
 @SpringBootTest
 @AutoConfigureMockMvc
+@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "maxPollingAttempts=2"})
 public class NodesControllerTest {
 
     private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -139,4 +149,66 @@ public class NodesControllerTest {
 
         verify(client, times(1)).getNode(eq("/a/b/c"));
     }
+
+    @Test
+    public void testMoveNodeSuccess() throws Exception {
+
+        when(client.getJobPhase("job_id"))
+                .thenReturn(ExecutionPhase.EXECUTING)
+                .thenReturn(ExecutionPhase.COMPLETED);
+
+        String response = testMoveNode()
+                .andExpect(status().isOk())
+                .andReturn().getResponse()
+                .getContentAsString();
+
+        Job job = MAPPER.readValue(response, Job.class);
+        assertEquals(ExecutionPhase.COMPLETED, job.getPhase());
+    }
+
+    @Test
+    public void testMoveNodeExecuting() throws Exception {
+
+        when(client.getJobPhase("job_id"))
+                .thenReturn(ExecutionPhase.EXECUTING);
+
+        String response = testMoveNode()
+                .andExpect(status().isOk())
+                .andReturn().getResponse()
+                .getContentAsString();
+
+        Job job = MAPPER.readValue(response, Job.class);
+        assertEquals(ExecutionPhase.EXECUTING, job.getPhase());
+    }
+
+    @Test
+    public void testMoveNodeError() throws Exception {
+
+        when(client.getJobPhase("job_id"))
+                .thenReturn(ExecutionPhase.ERROR);
+
+        when(client.getErrorDetail("job_id")).thenReturn("move_error");
+
+        try {
+            testMoveNode();
+            fail("Exception was expected");
+        } catch (Exception ex) {
+            assertTrue(ex.getCause() instanceof VOSpaceException);
+            assertTrue(ex.getCause().getMessage().contains("move_error"));
+        }
+    }
+
+    private ResultActions testMoveNode() throws Exception {
+
+        JobSummary job = new JobSummary();
+        job.setJobId("job_id");
+
+        when(client.startTransferJob(any())).thenReturn(job);
+
+        Map<String, String> params = Map.of("target", "/path/to/target", "direction", "/path/to/direction");
+
+        return mockMvc.perform(post("/move")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(MAPPER.writeValueAsString(params)));
+    }
 }
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index f61753d..ffa8604 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -207,11 +207,16 @@ export default new Vuex.Store({
           dispatch('setPath', state.path);
         });
     },
-    moveNode({ state, dispatch }, data) {
+    moveNode({ state, commit, dispatch }, data) {
       client.moveNode(data)
-        .then(() => {
-          // Reload current node
-          dispatch('setPath', state.path);
+        .then(job => {
+          if (job.phase === 'COMPLETED') {
+            // Reload current node
+            dispatch('setPath', state.path);
+          } else {
+            main.showInfo('Move operation is taking some time and will be handled in background');
+            commit('addJob', job);
+          }
         });
     },
     openNodeInMoveModal({ state, commit }, path) {
-- 
GitLab