diff --git a/pom.xml b/pom.xml
index 294419cc..bfc02bac 100644
--- a/pom.xml
+++ b/pom.xml
@@ -112,6 +112,12 @@
slf4j-api
2.0.13
+
+ org.junit.jupiter
+ junit-jupiter-params
+ 5.10.0
+ test
+
diff --git a/src/main/java/Main.java b/src/main/java/Main.java
index a533f101..41a88ac5 100644
--- a/src/main/java/Main.java
+++ b/src/main/java/Main.java
@@ -31,7 +31,7 @@ public static void main(String[] args) {
throw new RuntimeException(e);
}
- new Server(endpointProvider).start(8080);
+ new Server(endpointProvider).start();
try {
SubmissionService.reRunSubmissionsInQueue();
diff --git a/src/main/java/edu/byu/cs/controller/UserController.java b/src/main/java/edu/byu/cs/controller/UserController.java
index 48e8a7c6..e03f2c77 100644
--- a/src/main/java/edu/byu/cs/controller/UserController.java
+++ b/src/main/java/edu/byu/cs/controller/UserController.java
@@ -18,16 +18,16 @@
import static spark.Spark.halt;
public class UserController {
- public static final Route repoPatch = (req, res) -> {
+ public static final Route setRepoUrl = (req, res) -> {
User user = req.session().attribute("user");
- applyRepoPatch(user.netId(), null, req, res);
+ setRepoUrl(user.netId(), null, req, res);
return "Successfully updated repoUrl";
};
- public static final Route repoPatchAdmin = (req, res) -> {
+ public static final Route setRepoUrlAdmin = (req, res) -> {
User admin = req.session().attribute("user");
String studentNetId = req.params(":netId");
- applyRepoPatch(studentNetId, admin.netId(), req, res);
+ setRepoUrl(studentNetId, admin.netId(), req, res);
return "Successfully updated repoUrl for user: " + studentNetId;
};
@@ -52,7 +52,7 @@ public class UserController {
return Serializer.serialize(updates);
};
- private static void applyRepoPatch(String studentNetId, String adminNetId, Request req, Response res) {
+ private static void setRepoUrl(String studentNetId, String adminNetId, Request req, Response res) {
JsonObject jsonObject = new Gson().fromJson(req.body(), JsonObject.class);
String repoUrl = new Gson().fromJson(jsonObject.get("repoUrl"), String.class);
diff --git a/src/main/java/edu/byu/cs/server/Server.java b/src/main/java/edu/byu/cs/server/Server.java
index c2deb6be..7ee46988 100644
--- a/src/main/java/edu/byu/cs/server/Server.java
+++ b/src/main/java/edu/byu/cs/server/Server.java
@@ -4,6 +4,7 @@
import edu.byu.cs.server.endpointprovider.EndpointProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import spark.Spark;
import static spark.Spark.*;
@@ -16,12 +17,20 @@ public Server(EndpointProvider endpointProvider) {
this.provider = endpointProvider;
}
+ public int start() {
+ return start(0);
+ }
+
public int start(int desiredPort) {
int chosenPort = setupEndpoints(desiredPort);
LOGGER.info("Server started on port {}", chosenPort);
return chosenPort;
}
+ public void stop() {
+ Spark.stop();
+ }
+
private int setupEndpoints(int port) {
port(port);
@@ -45,7 +54,7 @@ private int setupEndpoints(int port) {
if (!req.requestMethod().equals("OPTIONS")) provider.verifyAuthenticatedMiddleware().handle(req, res);
});
- patch("/repo", provider.repoPatch());
+ post("/repo", provider.setRepoUrl());
get("/submit", provider.submitGet());
post("/submit", provider.submitPost());
@@ -64,7 +73,7 @@ private int setupEndpoints(int port) {
if (!req.requestMethod().equals("OPTIONS")) provider.verifyAdminMiddleware().handle(req, res);
});
- patch("/repo/:netId", provider.repoPatchAdmin());
+ post("/repo/:netId", provider.setRepoUrlAdmin());
get("/repo/history", provider.repoHistoryAdminGet());
@@ -118,6 +127,8 @@ private int setupEndpoints(int port) {
init();
+ awaitInitialization();
+
return port();
}
}
diff --git a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java
index b89c1d6a..f3547981 100644
--- a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java
+++ b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java
@@ -58,7 +58,7 @@ public interface EndpointProvider {
// UserController
- Route repoPatch();
- Route repoPatchAdmin();
+ Route setRepoUrl();
+ Route setRepoUrlAdmin();
Route repoHistoryAdminGet();
}
diff --git a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java
index 1ba31d02..7d52d9ed 100644
--- a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java
+++ b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java
@@ -13,7 +13,7 @@ public class EndpointProviderImpl implements EndpointProvider {
public Filter beforeAll() {
return (request, response) -> {
response.header("Access-Control-Allow-Headers", "Authorization,Content-Type");
- response.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,PATCH,OPTIONS");
+ response.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
response.header("Access-Control-Allow-Credentials", "true");
response.header("Access-Control-Allow-Origin", ApplicationProperties.frontendUrl());
};
@@ -195,13 +195,13 @@ public Route submissionsReRunPost() {
// UserController
@Override
- public Route repoPatch() {
- return UserController.repoPatch;
+ public Route setRepoUrl() {
+ return UserController.setRepoUrl;
}
@Override
- public Route repoPatchAdmin() {
- return UserController.repoPatchAdmin;
+ public Route setRepoUrlAdmin() {
+ return UserController.setRepoUrlAdmin;
}
@Override
diff --git a/src/test/java/edu/byu/cs/server/ServerTest.java b/src/test/java/edu/byu/cs/server/ServerTest.java
new file mode 100644
index 00000000..9437fac4
--- /dev/null
+++ b/src/test/java/edu/byu/cs/server/ServerTest.java
@@ -0,0 +1,151 @@
+package edu.byu.cs.server;
+
+import edu.byu.cs.server.endpointprovider.MockEndpointProvider;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.InOrder;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Stream;
+
+import static org.mockito.Mockito.*;
+
+class ServerTest {
+ private static TestServerFacade serverFacade;
+ private static Server server;
+
+ private static final MockEndpointProvider mockedMockProvider = spy(new MockEndpointProvider());
+
+ public static Stream getPathParamEndpoints() {
+ return Stream.of(
+ Arguments.of( "GET", "/api/submission", "submissionXGet", ":phase"),
+ Arguments.of( "GET", "/api/admin/analytics/commit", "commitAnalyticsGet", ":option"),
+ Arguments.of("POST", "/api/admin/repo", "setRepoUrlAdmin", ":netid"),
+ Arguments.of( "GET", "/api/admin/honorChecker/zip", "honorCheckerZipGet", ":section"),
+ Arguments.of( "GET", "/api/admin/submissions/latest", "latestSubmissionsGet", ":count"),
+ Arguments.of( "GET", "/api/admin/submissions/student", "studentSubmissionsGet", ":netid")
+ );
+ // api/admin/config/penalties
+ }
+
+ public static Stream getEndpoints() {
+ return Stream.of(
+ Arguments.of( "GET", "/auth/callback", "callbackGet"),
+ Arguments.of( "GET", "/auth/login", "loginGet"),
+ Arguments.of("POST", "/auth/logout", "logoutPost"),
+
+ Arguments.of( "GET", "/api/config", "getConfigStudent"),
+ Arguments.of( "GET", "/api/latest", "latestSubmissionForMeGet"),
+ Arguments.of( "GET", "/api/me", "meGet"),
+ Arguments.of("POST", "/api/repo", "setRepoUrl"),
+ Arguments.of( "GET", "/api/submission", "submissionXGet"),
+ Arguments.of( "GET", "/api/submit", "submitGet"),
+ Arguments.of("POST", "/api/submit", "submitPost"),
+
+ Arguments.of( "GET", "/api/admin/config", "getConfigAdmin"),
+ Arguments.of("POST", "/api/admin/config/banner", "updateBannerMessage"),
+ Arguments.of("POST", "/api/admin/config/courseIds", "updateCourseIdsPost"),
+ Arguments.of( "GET", "/api/admin/config/courseIds", "updateCourseIdsUsingCanvasGet"),
+ Arguments.of("POST", "/api/admin/config/penalties", "updatePenalties"),
+ Arguments.of("POST", "/api/admin/config/phases", "updateLivePhases"),
+ Arguments.of("POST", "/api/admin/config/phases/shutdown", "scheduleShutdown"),
+
+ Arguments.of( "GET", "/api/admin/submissions/active", "submissionsActiveGet"),
+ Arguments.of("POST", "/api/admin/submissions/approve", "approveSubmissionPost"),
+ Arguments.of( "GET", "/api/admin/submissions/latest", "latestSubmissionsGet"),
+ Arguments.of("POST", "/api/admin/submissions/rerun", "submissionsReRunPost"),
+ Arguments.of("POST", "/api/admin/submit", "adminRepoSubmitPost"),
+
+ Arguments.of( "GET", "/api/admin/analytics/commit", "commitAnalyticsGet"),
+ Arguments.of( "GET", "/api/admin/repo/history", "repoHistoryAdminGet"),
+ Arguments.of( "GET", "/api/admin/sections", "sectionsGet"),
+ Arguments.of( "GET", "/api/admin/test_mode", "testModeGet"),
+ Arguments.of( "GET", "/api/admin/users", "usersGet")
+ );
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.stop();
+ }
+
+ @BeforeAll
+ public static void init() {
+ server = new Server(mockedMockProvider);
+ int port = server.start();
+ System.out.println("Started test HTTP server on " + port);
+
+ serverFacade = new TestServerFacade("localhost", port);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ reset(mockedMockProvider);
+ }
+
+ @ParameterizedTest
+ @MethodSource("getEndpoints")
+ public void verifyEndpointCallsItsHandlersExactlyOnceInOrder(String method, String path, String endpointName) throws IOException {
+ // When
+ serverFacade.makeRequest(method, path);
+
+ InOrder inOrder = inOrder(mockedMockProvider);
+
+ // Then
+ inOrder.verify(mockedMockProvider, times(1)).runHandler("beforeAll");
+ this.verifyInOrder_authenticationMiddleware(path, inOrder);
+ inOrder.verify(mockedMockProvider, times(1)).runHandler(endpointName);
+ inOrder.verify(mockedMockProvider, times(1)).runHandler("afterAll");
+ }
+
+ @ParameterizedTest
+ @MethodSource("getPathParamEndpoints")
+ public void verifyPathParameterHasAValueWhenGivenOne(String method, String path, String endpointName,
+ String pathParamName) throws IOException {
+ // Given
+ String fullPath = path + "/testParamValue";
+
+ // When
+ serverFacade.makeRequest(method, fullPath);
+
+ // Then
+ verify(mockedMockProvider, times(1)).hasPathParam(endpointName, pathParamName, "testParamValue");
+ }
+
+ private void verifyInOrder_authenticationMiddleware(String path, InOrder inOrder) {
+ List pathNodes = Arrays.stream(path.split("/")).toList();
+
+ if (!pathNodes.contains("api")) {
+ return;
+ }
+
+ if (!pathNodes.contains("auth")) {
+ // Requires authentication
+ inOrder.verify(mockedMockProvider, times(1)).runHandler("verifyAuthenticatedMiddleware");
+ }
+
+ if (pathNodes.contains("admin")) {
+ // Requires admin
+ inOrder.verify(mockedMockProvider, times(1)).runHandler("verifyAdminMiddleware");
+ }
+ }
+
+ @Test
+ void nonexistent_GET_endpoint_calls_beforeAll_then_defaultGet_then_afterAll_exactly_once_in_order() throws IOException {
+ serverFacade.makeRequest("GET", "/iDoNotExist");
+
+ // Verify they ran in order
+ InOrder inOrder = inOrder(mockedMockProvider);
+ inOrder.verify(mockedMockProvider).runHandler("beforeAll");
+ inOrder.verify(mockedMockProvider).runHandler("defaultGet");
+ inOrder.verify(mockedMockProvider).runHandler("afterAll");
+
+ // Verify they only ran once
+ verify(mockedMockProvider, times(1)).runHandler("beforeAll");
+ verify(mockedMockProvider, times(1)).runHandler("defaultGet");
+ verify(mockedMockProvider, times(1)).runHandler("afterAll");
+ }
+}
diff --git a/src/test/java/edu/byu/cs/server/TestServerFacade.java b/src/test/java/edu/byu/cs/server/TestServerFacade.java
new file mode 100644
index 00000000..43ffc4ce
--- /dev/null
+++ b/src/test/java/edu/byu/cs/server/TestServerFacade.java
@@ -0,0 +1,109 @@
+package edu.byu.cs.server;
+
+import com.google.gson.Gson;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Map;
+
+public class TestServerFacade {
+ private final String serverURL;
+
+ public TestServerFacade(String serverURL, int port) {
+ this.serverURL = "http://%s:%d".formatted(serverURL, port);
+ }
+
+ public Object makeRequest(String method, String path) throws IOException {
+ return makeRequest(method, path, null, null, Object.class);
+ }
+
+ public T makeRequest(String method, String path, Object request, Map headers,
+ Class responseClass) throws IOException {
+ HttpURLConnection http = getConnection(method, serverURL + path);
+ writeRequest(http, request, headers);
+ connect(http);
+ return readResponse(http, responseClass);
+ }
+
+ private HttpURLConnection getConnection(String method, String urlString) throws IOException {
+ try {
+ URL url = (new URI(urlString)).toURL();
+ HttpURLConnection http = (HttpURLConnection) url.openConnection();
+ http.setRequestMethod(method);
+ http.setDoOutput("POST".equals(method) || "PUT".equals(method));
+ return http;
+ } catch (IOException | URISyntaxException e) {
+ throw new IOException("Failed to set up HTTP connection: " + e.getMessage(), e);
+ }
+ }
+
+ private void writeRequest(HttpURLConnection http, Object requestBody, Map headers) throws IOException {
+ try {
+ writeHeaders(http, headers);
+ writeRequestBody(http, requestBody);
+ } catch (IOException e) {
+ throw new IOException("Could not write request body: " + e.getMessage(), e);
+ }
+ }
+
+ private void connect(HttpURLConnection http) throws IOException {
+ try {
+ http.connect();
+ } catch (IOException e) {
+ throw new IOException("Failed to connect to server: " + e.getMessage(), e);
+ }
+ }
+
+ private T readResponse(HttpURLConnection http, Class responseClass) throws IOException {
+ String respString = getRespString(http);
+ try {
+ return new Gson().fromJson(respString, responseClass);
+ } catch (Exception e) {
+ String message = String.format("Error parsing response. Expected JSON, got '%s'", respString);
+ throw new IOException(message, e);
+ }
+ }
+
+ private void writeHeaders(HttpURLConnection http, Map headers) {
+ http.addRequestProperty("Content-type", "application/json");
+ if (headers != null) {
+ for (String headerName : headers.keySet()) {
+ String headerValue = headers.get(headerName);
+ http.addRequestProperty(headerName, headerValue);
+ }
+ }
+ }
+
+ private void writeRequestBody(HttpURLConnection http, Object request) throws IOException {
+ if (request != null) {
+ String requestData = new Gson().toJson(request);
+ try (OutputStream reqBody = http.getOutputStream()) {
+ reqBody.write(requestData.getBytes());
+ }
+ }
+ }
+
+ private String getRespString(HttpURLConnection http) throws IOException {
+ InputStream respBody;
+ if (http.getResponseCode() / 100 == 2) {
+ respBody = http.getInputStream();
+ } else {
+ respBody = http.getErrorStream();
+ }
+ StringBuilder sb = new StringBuilder();
+ InputStreamReader sr = new InputStreamReader(respBody);
+ char[] buf = new char[1024];
+ int len;
+ while ((len = sr.read(buf)) > 0) {
+ sb.append(buf, 0, len);
+ }
+ respBody.close();
+ return sb.toString();
+ }
+}
diff --git a/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java b/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java
new file mode 100644
index 00000000..1b977b9c
--- /dev/null
+++ b/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java
@@ -0,0 +1,226 @@
+package edu.byu.cs.server.endpointprovider;
+
+import spark.Filter;
+import spark.Request;
+import spark.Response;
+import spark.Route;
+
+import java.util.Map;
+
+/**
+ * A mock implementation of EndpointProvider designed for use with
+ * Mockito.spy() (not mock()). Because individual endpoint methods only run once at
+ * Server startup, they aren't useful for verifying endpoint calls.
+ * MockEndpointProvider provides a runHandler() method that can be used for
+ * that purpose.
+ */
+public class MockEndpointProvider implements EndpointProvider {
+
+ /**
+ * An empty function that is run by every endpoint, designed for use in
+ * Mockito.verify() to verify endpoint calls. The individual endpoint
+ * methods only run once on Server startup, so they can't be used
+ * for that purpose.
+ *
+ * @param endpointName the name of the Route that was called
+ */
+ public void runHandler(String endpointName) {}
+
+ /**
+ * An empty function that is run for each path parameter in each endpoint
+ * that is called. It is designed for use with Mockito.verify() to verify
+ * that endpoints are called with specific path parameters.
+ *
+ * @param endpointName the name of the Route that was called
+ * @param paramName the name of the parameter
+ * @param paramValue the value of the parameter
+ */
+ public void hasPathParam(String endpointName, String paramName, String paramValue) {}
+
+ private Object extractRequestInfo(String endpointName, Request req, Response res) {
+ Map params = req.params();
+ for (String paramName : params.keySet()) {
+ String paramValue = params.get(paramName);
+ this.hasPathParam(endpointName, paramName, paramValue);
+ }
+
+ this.runHandler(endpointName);
+
+ return "{}";
+ }
+
+ @Override
+ public Filter beforeAll() {
+ return (req, res) -> extractRequestInfo("beforeAll", req, res);
+ }
+
+ @Override
+ public Filter afterAll() {
+ return (req, res) -> extractRequestInfo("afterAll", req, res);
+ }
+
+ @Override
+ public Route defaultGet() {
+ return (req, res) -> extractRequestInfo("defaultGet", req, res);
+ }
+
+ @Override
+ public Route usersGet() {
+ return (req, res) -> extractRequestInfo("usersGet", req, res);
+ }
+
+ @Override
+ public Route testModeGet() {
+ return (req, res) -> extractRequestInfo("testModeGet", req, res);
+ }
+
+ @Override
+ public Route commitAnalyticsGet() {
+ return (req, res) -> extractRequestInfo("commitAnalyticsGet", req, res);
+ }
+
+ @Override
+ public Route honorCheckerZipGet() {
+ return (req, res) -> extractRequestInfo("honorCheckerZipGet", req, res);
+ }
+
+ @Override
+ public Route sectionsGet() {
+ return (req, res) -> extractRequestInfo("sectionsGet", req, res);
+ }
+
+ @Override
+ public Filter verifyAuthenticatedMiddleware() {
+ return (req, res) -> extractRequestInfo("verifyAuthenticatedMiddleware", req, res);
+ }
+
+ @Override
+ public Filter verifyAdminMiddleware() {
+ return (req, res) -> extractRequestInfo("verifyAdminMiddleware", req, res);
+ }
+
+ @Override
+ public Route meGet() {
+ return (req, res) -> extractRequestInfo("meGet", req, res);
+ }
+
+ @Override
+ public Route callbackGet() {
+ return (req, res) -> extractRequestInfo("callbackGet", req, res);
+ }
+
+ @Override
+ public Route loginGet() {
+ return (req, res) -> extractRequestInfo("loginGet", req, res);
+ }
+
+ @Override
+ public Route logoutPost() {
+ return (req, res) -> extractRequestInfo("logoutPost", req, res);
+ }
+
+ @Override
+ public Route getConfigAdmin() {
+ return (req, res) -> extractRequestInfo("getConfigAdmin", req, res);
+ }
+
+ @Override
+ public Route getConfigStudent() {
+ return (req, res) -> extractRequestInfo("getConfigStudent", req, res);
+ }
+
+ @Override
+ public Route updateLivePhases() {
+ return (req, res) -> extractRequestInfo("updateLivePhases", req, res);
+ }
+
+ @Override
+ public Route scheduleShutdown() {
+ return (req, res) -> extractRequestInfo("scheduleShutdown", req, res);
+ }
+
+ @Override
+ public Route updateBannerMessage() {
+ return (req, res) -> extractRequestInfo("updateBannerMessage", req, res);
+ }
+
+ @Override
+ public Route updateCourseIdsPost() {
+ return (req, res) -> extractRequestInfo("updateCourseIdsPost", req, res);
+ }
+
+ @Override
+ public Route updateCourseIdsUsingCanvasGet() {
+ return (req, res) -> extractRequestInfo("updateCourseIdsUsingCanvasGet", req, res);
+ }
+
+ @Override
+ public Route updatePenalties() {
+ return (req, res) -> extractRequestInfo("updatePenalties", req, res);
+ }
+
+ @Override
+ public Route submitPost() {
+ return (req, res) -> extractRequestInfo("submitPost", req, res);
+ }
+
+ @Override
+ public Route adminRepoSubmitPost() {
+ return (req, res) -> extractRequestInfo("adminRepoSubmitPost", req, res);
+ }
+
+ @Override
+ public Route submitGet() {
+ return (req, res) -> extractRequestInfo("submitGet", req, res);
+ }
+
+ @Override
+ public Route latestSubmissionForMeGet() {
+ return (req, res) -> extractRequestInfo("latestSubmissionForMeGet", req, res);
+ }
+
+ @Override
+ public Route submissionXGet() {
+ return (req, res) -> extractRequestInfo("submissionXGet", req, res);
+ }
+
+ @Override
+ public Route latestSubmissionsGet() {
+ return (req, res) -> extractRequestInfo("latestSubmissionsGet", req, res);
+ }
+
+ @Override
+ public Route submissionsActiveGet() {
+ return (req, res) -> extractRequestInfo("submissionsActiveGet", req, res);
+ }
+
+ @Override
+ public Route studentSubmissionsGet() {
+ return (req, res) -> extractRequestInfo("studentSubmissionsGet", req, res);
+ }
+
+ @Override
+ public Route approveSubmissionPost() {
+ return (req, res) -> extractRequestInfo("approveSubmissionPost", req, res);
+ }
+
+ @Override
+ public Route submissionsReRunPost() {
+ return (req, res) -> extractRequestInfo("submissionsReRunPost", req, res);
+ }
+
+ @Override
+ public Route setRepoUrl() {
+ return (req, res) -> extractRequestInfo("setRepoUrl", req, res);
+ }
+
+ @Override
+ public Route setRepoUrlAdmin() {
+ return (req, res) -> extractRequestInfo("setRepoUrlAdmin", req, res);
+ }
+
+ @Override
+ public Route repoHistoryAdminGet() {
+ return (req, res) -> extractRequestInfo("repoHistoryAdminGet", req, res);
+ }
+}