From 1c4617edd29250acafdfb87d92ea497075a01abb Mon Sep 17 00:00:00 2001 From: Michael Huster Date: Tue, 2 Jul 2024 13:01:41 +0200 Subject: [PATCH 1/3] refactor(model-server): extract IndexPage --- .../kotlin/org/modelix/model/server/Main.kt | 56 +------------ .../model/server/handlers/ui/IndexPage.kt | 84 +++++++++++++++++++ 2 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/IndexPage.kt diff --git a/model-server/src/main/kotlin/org/modelix/model/server/Main.kt b/model-server/src/main/kotlin/org/modelix/model/server/Main.kt index eaf59e4042..304e0d17d8 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/Main.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/Main.kt @@ -25,7 +25,6 @@ import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer -import io.ktor.server.html.respondHtmlTemplate import io.ktor.server.http.content.staticResources import io.ktor.server.netty.Netty import io.ktor.server.netty.NettyApplicationEngine @@ -44,12 +43,6 @@ import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.pingPeriod import io.ktor.server.websocket.timeout -import kotlinx.html.a -import kotlinx.html.h1 -import kotlinx.html.li -import kotlinx.html.style -import kotlinx.html.ul -import kotlinx.html.unsafe import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.apache.commons.io.FileUtils @@ -71,6 +64,7 @@ import org.modelix.model.server.handlers.ModelReplicationServer import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.handlers.ui.ContentExplorer import org.modelix.model.server.handlers.ui.HistoryHandler +import org.modelix.model.server.handlers.ui.IndexPage import org.modelix.model.server.handlers.ui.RepositoryOverview import org.modelix.model.server.store.IgniteStoreClient import org.modelix.model.server.store.InMemoryStoreClient @@ -80,7 +74,6 @@ import org.modelix.model.server.store.forContextRepository import org.modelix.model.server.store.forGlobalRepository import org.modelix.model.server.store.loadDump import org.modelix.model.server.store.writeDump -import org.modelix.model.server.templates.PageWithMenuBar import org.slf4j.LoggerFactory import org.springframework.util.ResourceUtils import java.io.File @@ -217,6 +210,7 @@ object Main { installStatusPages() modelServer.init(this) + IndexPage().init(this) historyHandler.init(this) repositoryOverview.init(this) contentExplorer.init(this) @@ -224,54 +218,12 @@ object Main { modelReplicationServer.init(this) metricsApi.init(this) IdsApiImpl(repositoriesManager, localModelClient).init(this) + routing { HealthApiImpl(repositoriesManager, globalStoreClient, inMemoryModels).installRoutes(this) staticResources("/public", "public") - get("/") { - call.respondHtmlTemplate(PageWithMenuBar("root", ".")) { - headContent { - style { - unsafe { - raw( - """ - body { - font-family: sans-serif; - table { - border-collapse: collapse; - } - td, th { - border: 1px solid #888; - padding: 3px 12px; - } - """.trimIndent(), - ) - } - } - } - bodyContent { - h1 { +"Model Server" } - ul { - li { - a("repos/") { +"View Repositories on the Model Server" } - } - li { - a("json/") { +"JSON API for JavaScript clients" } - } - li { - a("headers") { +"View HTTP headers" } - } - li { - a("user") { +"View JWT token and permissions" } - } - li { - a("swagger") { +"SwaggerUI" } - } - } - } - } - call.respondText("Model Server") - } + if (cmdLineArgs.noSwaggerUi) { get("swagger") { call.respondText("SwaggerUI is disabled") diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/IndexPage.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/IndexPage.kt new file mode 100644 index 0000000000..813a1f0f8e --- /dev/null +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/IndexPage.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024. + * + * 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 org.modelix.model.server.handlers.ui + +import io.ktor.server.application.Application +import io.ktor.server.application.call +import io.ktor.server.html.respondHtmlTemplate +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.html.a +import kotlinx.html.h1 +import kotlinx.html.li +import kotlinx.html.style +import kotlinx.html.ul +import kotlinx.html.unsafe +import org.modelix.model.server.templates.PageWithMenuBar + +/** + * Landing page of the model-server with links to other pages. + */ +class IndexPage { + + fun init(application: Application) { + application.routing { + get("/") { + call.respondHtmlTemplate(PageWithMenuBar("root", ".")) { + headContent { + style { + unsafe { + raw( + """ + body { + font-family: sans-serif; + table { + border-collapse: collapse; + } + td, th { + border: 1px solid #888; + padding: 3px 12px; + } + """.trimIndent(), + ) + } + } + } + bodyContent { + h1 { +"Model Server" } + ul { + li { + a("repos/") { +"View Repositories on the Model Server" } + } + li { + a("json/") { +"JSON API for JavaScript clients" } + } + li { + a("headers") { +"View HTTP headers" } + } + li { + a("user") { +"View JWT token and permissions" } + } + li { + a("swagger") { +"SwaggerUI" } + } + } + } + } + } + } + } +} From c6a0f16d70b223a1e53f51ee7417aa38b1264414 Mon Sep 17 00:00:00 2001 From: Michael Huster Date: Tue, 2 Jul 2024 15:28:08 +0200 Subject: [PATCH 2/3] test(model-server): migrate cucumber tests to ktor tests --- .../model/server/handlers/HealthApiImpl.kt | 2 +- .../server/functionaltests/Stepdefs.java | 419 ------------------ .../org/modelix/model/server/V1ApiTest.kt | 358 +++++++++++++++ .../model/server/handlers/HealthApiTest.kt | 80 ++++ .../model/server/handlers/ui/IndexPageTest.kt | 48 ++ .../functionaltests/basic_routes.feature | 14 - .../resources/functionaltests/counter.feature | 16 - .../resources/functionaltests/storing.feature | 70 --- .../functionaltests/subscription.feature | 14 - .../resources/functionaltests/token.feature | 9 - .../functionaltests/userinfo.feature | 35 -- 11 files changed, 487 insertions(+), 578 deletions(-) delete mode 100644 model-server/src/test/java/org/modelix/model/server/functionaltests/Stepdefs.java create mode 100644 model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt create mode 100644 model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt create mode 100644 model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt delete mode 100644 model-server/src/test/resources/functionaltests/basic_routes.feature delete mode 100644 model-server/src/test/resources/functionaltests/counter.feature delete mode 100644 model-server/src/test/resources/functionaltests/storing.feature delete mode 100644 model-server/src/test/resources/functionaltests/subscription.feature delete mode 100644 model-server/src/test/resources/functionaltests/token.feature delete mode 100644 model-server/src/test/resources/functionaltests/userinfo.feature diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/HealthApiImpl.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/HealthApiImpl.kt index c8c4ffe781..89685b4873 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/HealthApiImpl.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/HealthApiImpl.kt @@ -65,6 +65,6 @@ class HealthApiImpl( } companion object { - private const val HEALTH_KEY = PROTECTED_PREFIX + "health2" + internal const val HEALTH_KEY = PROTECTED_PREFIX + "health2" } } diff --git a/model-server/src/test/java/org/modelix/model/server/functionaltests/Stepdefs.java b/model-server/src/test/java/org/modelix/model/server/functionaltests/Stepdefs.java deleted file mode 100644 index 3f3b6c83f3..0000000000 --- a/model-server/src/test/java/org/modelix/model/server/functionaltests/Stepdefs.java +++ /dev/null @@ -1,419 +0,0 @@ -/* - * 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 org.modelix.model.server.functionaltests; - -import static org.junit.Assert.*; - -import com.google.common.base.Charsets; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import io.cucumber.java.After; -import io.cucumber.java.Before; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.ConnectException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.*; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; -import javax.ws.rs.client.WebTarget; -import javax.ws.rs.sse.InboundSseEvent; -import javax.ws.rs.sse.SseEventSource; - -public class Stepdefs { - - private Process p; - private int nRetries; - private SseEventSource source; - private List events = new LinkedList(); - private List> allStringResponses = new LinkedList<>(); - - private Executor longPollExecutor = Executors.newCachedThreadPool(); - private List longPollResults = new ArrayList(); - - private static final boolean VERBOSE_SERVER = false; - private static final boolean VERBOSE_CONNECTION = false; - - @Before - public void prepare() { - nRetries = 10; - } - - @After - public void cleanup() { - if (p != null) { - p.destroy(); - p = null; - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - } - } - if (source != null) { - source.close(); - } - source = null; - events.clear(); - allStringResponses.clear(); - } - - @Given("the server has been started with in-memory storage") - public void the_server_has_been_started_with_in_memory_storage() { - startServerInMemory(Collections.emptyMap()); - } - - @Given("the server has been started with in-memory storage loaded with {string}") - public void theServerHasBeenStartedWithInMemoryStorageLoadedWith(String presetValuesStr) { - Map presetValues = new HashMap<>(); - Arrays.stream(presetValuesStr.split(",")) - .forEach( - s -> { - String[] parts = s.split("="); - presetValues.put(parts[0].strip(), parts[1].strip()); - }); - startServerInMemory(presetValues); - } - - private void startServerInMemory(Map presetValues) { - try { - String argsToSetValues = - presetValues.entrySet().stream() - .map(e -> " -set " + e.getKey() + " " + e.getValue()) - .collect(Collectors.joining()); - File modelServerJar = new File("build/libs/model-server-latest-fatJar.jar"); - if (!modelServerJar.exists()) { - throw new RuntimeException( - "Model server jar not found at " + modelServerJar.getAbsolutePath()); - } - String commandLine = - "java -jar " - + modelServerJar.getAbsolutePath() - + " -inmemory" - + argsToSetValues; - p = Runtime.getRuntime().exec(commandLine); - BufferedReader stdInput = new BufferedReader(new InputStreamReader(p.getInputStream())); - - BufferedReader stdError = new BufferedReader(new InputStreamReader(p.getErrorStream())); - - new Thread( - () -> { - try { - String s; - while ((s = stdInput.readLine()) != null) { - if (VERBOSE_SERVER) { - System.out.println("SERVER OUT " + s); - } - } - - while ((s = stdError.readLine()) != null) { - if (VERBOSE_SERVER) { - System.out.println("SERVER ERR " + s); - } - } - } catch (IOException e) { - // this may happen when closing - } - }) - .start(); - - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - } - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @When("I visit {string}") - public void i_visit(String path) { - httpRequest("GET", path); - } - - private String httpRequest(String method, String path) { - try { - var client = HttpClient.newHttpClient(); - var request = - HttpRequest.newBuilder(URI.create("http://localhost:28101" + path)) - .method(method, HttpRequest.BodyPublishers.noBody()) - .header("accept", "application/json") - .build(); - - HttpResponse result = - client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)); - allStringResponses.add(result); - return result.body(); - } catch (ConnectException e) { - if (nRetries > 0) { - if (VERBOSE_CONNECTION) { - System.out.println( - " (connection failed, retrying in a bit. nRetries=" + nRetries + ")"); - } - nRetries--; - try { - Thread.sleep(1000); - } catch (InterruptedException e2) { - - } - return httpRequest(method, path); - } else { - throw new RuntimeException(e); - } - } catch (IOException | InterruptedException e) { - e.printStackTrace(); - return null; - } - } - - @When("I POST {string}") - public void iPOST(String path) { - httpRequest("POST", path); - } - - @When("I visit {string} with headers {string}") - public void iVisitWithHeaders(String path, String headersStr) { - Map headers = new HashMap<>(); - Arrays.stream(headersStr.split(",")) - .forEach( - s -> { - String[] parts = s.split("="); - headers.put(parts[0].strip(), parts[1].strip()); - }); - visitPath(path, headers); - } - - @When("I visit {string} with header {string} set to {string}") - public void iVisitWithHeaderSetTo(String path, String header, String value) { - visitPath(path, Collections.singletonMap(header, value)); - } - - private String lastStringResponse() { - return allStringResponses.get(allStringResponses.size() - 1).body().strip(); - } - - private int lastStatusCode() { - return allStringResponses.get(allStringResponses.size() - 1).statusCode(); - } - - private void visitPath(String path, Map headers) { - try { - var client = HttpClient.newHttpClient(); - var builder = - HttpRequest.newBuilder(URI.create("http://localhost:28101" + path)) - .header("accept", "application/json"); - for (Map.Entry e : headers.entrySet()) { - String value = e.getValue(); - if (value.contains("#TEXT_OF_LAST_PAGE#")) { - value = value.replaceAll("#TEXT_OF_LAST_PAGE#", lastStringResponse()); - } - builder = builder.header(e.getKey(), value); - } - var request = builder.build(); - - allStringResponses.add( - client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))); - } catch (ConnectException e) { - if (nRetries > 0) { - if (VERBOSE_CONNECTION) { - System.out.println( - " (connection failed, retrying in a bit. nRetries=" + nRetries + ")"); - } - nRetries--; - try { - Thread.sleep(1000); - } catch (InterruptedException e2) { - - } - visitPath(path, headers); - } else { - throw new RuntimeException(e); - } - } catch (IOException | InterruptedException e) { - e.printStackTrace(); - } - } - - @When("I PUT on {string} the value {string}") - public void i_put_on_the_value(String path, String value) { - try { - var client = HttpClient.newHttpClient(); - var request = - HttpRequest.newBuilder(URI.create("http://localhost:28101" + path)) - .method("PUT", HttpRequest.BodyPublishers.ofString(value)) - .header("accept", "application/json") - .build(); - - allStringResponses.add( - client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))); - } catch (ConnectException e) { - if (nRetries > 0) { - if (VERBOSE_CONNECTION) { - System.out.println( - " (connection failed, retrying in a bit. nRetries=" + nRetries + ")"); - } - nRetries--; - try { - Thread.sleep(1000); - } catch (InterruptedException e2) { - - } - i_put_on_the_value(path, value); - } else { - throw new RuntimeException(e); - } - } catch (IOException | InterruptedException e) { - e.printStackTrace(); - } - } - - @Then("I should get an OK response") - public void i_should_get_an_ok_response() { - assertEquals(200, lastStatusCode()); - } - - @Then("I should get a NOT FOUND response") - public void i_should_get_a_not_found_response() { - assertEquals(404, lastStatusCode()); - } - - @Then("I should get an NO CONTENT response") - public void iShouldGetAnNOCONTENTResponse() { - assertEquals(204, lastStatusCode()); - } - - @Then("I should get a FORBIDDEN response") - public void iShouldGetAFORBIDDENResponse() { - assertEquals(403, lastStatusCode()); - } - - @Then("the text of the page should be {string}") - public void the_text_of_the_page_should_be(String expectedText) { - assertEquals(expectedText.strip(), lastStringResponse()); - } - - @Then("the text of the page should contain {string}") - public void the_text_of_the_page_should_contain(String expectedText) { - assertTrue(lastStringResponse().contains(expectedText.strip())); - } - - @Then("the text of the page should be {int} characters long") - public void theTextOfThePageShouldBeCharactersLong(int nLength) { - assertEquals(nLength, lastStringResponse().length()); - } - - @Then("the text of the page contains only hexadecimal digits") - public void theTextOfThePageContainsOnlyHexadecimalDigits() { - Pattern.matches("[a-f0-9]+", lastStringResponse()); - } - - @Then("the text of the page should be this JSON {string}") - public void theTextOfThePageShouldBeThisJSON(String expectedJsonStr) { - JsonElement expectedJson = JsonParser.parseString(expectedJsonStr); - assertEquals(expectedJson, JsonParser.parseString(lastStringResponse())); - } - - @Then("I should get an event {string}") - public void iShouldGetAnEvent(String expectedEventValue) { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - - } - assertTrue(events.stream().anyMatch(e -> e.readData().equals(expectedEventValue))); - } - - @Then("Long poll should return {string}") - public void longPollShouldReturn(String expectedValue) { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - - } - assertTrue(longPollResults.stream().anyMatch(e -> Objects.equals(expectedValue, e))); - } - - @Then("Long poll should NOT return {string}") - public void longPollShouldNOTReturn(String expectedValue) { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - - } - assertTrue(longPollResults.stream().noneMatch(e -> Objects.equals(expectedValue, e))); - } - - @Then("I should NOT get an event {string}") - public void iShouldNOTGetAnEvent(String expectedEventValue) { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - - } - assertTrue(events.stream().noneMatch(e -> e.readData().equals(expectedEventValue))); - } - - @When("I prepare to receive events from {string}") - public void iPrepareToReceiveEvents(String path) { - // wait for server to be up - i_visit("/"); - - Client client = ClientBuilder.newClient(); - WebTarget target = client.target("http://localhost:28101" + path); - - source = SseEventSource.target(target).build(); - source.register(inboundSseEvent -> events.add(inboundSseEvent)); - source.open(); - } - - @When("I long poll {string}") - public void iLongPoll(String path) { - // wait for server to be up - i_visit("/"); - - longPollExecutor.execute( - () -> { - String value = httpRequest("GET", path); - System.out.println("Polling " + path + " returned " + value); - longPollResults.add(value); - }); - } - - @Then("the text of the page should be the same as before") - public void theTextOfThePageShouldBeTheSameAsBefore() { - String last = allStringResponses.get(allStringResponses.size() - 1).body().strip(); - String secondToLast = allStringResponses.get(allStringResponses.size() - 2).body().strip(); - assertEquals(secondToLast, last); - } - - @Then("the text of the page should be different than before") - public void theTextOfThePageShouldBeDifferentThanBefore() { - String last = allStringResponses.get(allStringResponses.size() - 1).body().strip(); - String secondToLast = allStringResponses.get(allStringResponses.size() - 2).body().strip(); - assertNotEquals(secondToLast, last); - } -} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt new file mode 100644 index 0000000000..854bb0c81d --- /dev/null +++ b/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2024. + * + * 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 org.modelix.model.server + +import com.google.gson.JsonParser +import io.ktor.client.plugins.api.Send +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import org.junit.jupiter.api.Test +import org.modelix.authorization.installAuthentication +import org.modelix.model.InMemoryModels +import org.modelix.model.server.handlers.KeyValueLikeModelServer +import org.modelix.model.server.handlers.RepositoriesManager +import org.modelix.model.server.store.InMemoryStoreClient +import org.modelix.model.server.store.LocalModelClient +import org.modelix.model.server.store.forGlobalRepository +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class V1ApiTest { + + private fun runApiTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + val inMemoryModels = InMemoryModels() + val store = InMemoryStoreClient().forGlobalRepository() + val localModelClient = LocalModelClient(store) + val repositoriesManager = RepositoriesManager(localModelClient) + + application { + installAuthentication(unitTestMode = true) + installDefaultServerPlugins() + KeyValueLikeModelServer(repositoriesManager, store, inMemoryModels).init(this) + } + + block() + } + + @Test + fun `counter returns different ids for same key`() = runApiTest { + val url = "/counter/a" + + val response1 = client.post(url) + val response2 = client.post(url) + + assertEquals(HttpStatusCode.OK, response1.status) + assertEquals(HttpStatusCode.OK, response2.status) + assertNotEquals(response1.bodyAsText(), response2.bodyAsText()) + } + + @Test + fun `events are received after subscription`() = runApiTest { + val key = "dylandog" + val value = "a comic book" + + /* + The Mutex setup ensures the following execution order: + 1. GET is sent + 2. PUT is executed + 3. GET response is received + */ + val mutex = Mutex(locked = true) + val mutexClient = createMutexClient(mutex) + + val deferred = mutexClient.async { + mutexClient.get("/poll/$key") + } + mutex.lock() + client.put("/put/$key") { + setBody(value) + } + + val response = deferred.await() + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(value, response.bodyAsText()) + } + + @Test + fun `events are only received for the subscribed key`() = runApiTest { + val key = "dylandog" + val value = "a comic book" + + /* + The Mutex setup ensures the following execution order: + 1. GET is sent + 2. PUTs are executed + 3. GET response is received + */ + val mutex = Mutex(locked = true) + val mutexClient = createMutexClient(mutex) + val deferred = mutexClient.async { mutexClient.get("/poll/$key") } + mutex.lock() + client.put("/put/topolino") { + setBody(value) + } + client.put("/put/$key") { + setBody("someOtherValue") + } + + val response = deferred.await() + + assertEquals(HttpStatusCode.OK, response.status) + assertNotEquals(value, response.bodyAsText()) + } + + private fun ApplicationTestBuilder.createMutexClient(mutex: Mutex) = + client.config { + install( + createClientPlugin("mutexUnlock") { + on(Send) { request -> + mutex.unlock() + proceed(request) + } + }, + ) + } + + @Test + fun `default email after token is generated`() = runApiTest { + val response = client.get("/getEmail") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("unit-tests@example.com", response.bodyAsText()) + } + + @Test + fun `value can be stored and retrieved`() = runApiTest { + val key = "abc" + val value = "qwerty6789" + client.put("/put/$key") { + setBody(value) + } + val response = client.get("/get/$key") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(value, response.bodyAsText()) + } + + @Test + fun `retrieving non-existent key leads to not found`() = runApiTest { + val response = client.get("/get/abc") + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `retrieving forbidden key leads to forbidden`() = runApiTest { + val response = client.get("/get/$$\$_abc") + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `multiple existing keys can be retrieved`() = runApiTest { + val entries = mapOf( + "aaa" to "value1", + "bbb" to "value2", + "ccc" to "value3", + ) + for ((key, value) in entries) { + client.put("/put/$key") { setBody(value) } + } + + val response = client.put("/getAll") { + setBody("""["aaa", "bbb", "ccc"]""") + } + + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"value": "value1", "key": "aaa"}, + {"value": "value2", "key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + @Test + fun `multiple partially existing keys can be retrieved`() = runApiTest { + val entries = mapOf( + "aaa" to "value1", + "ccc" to "value3", + ) + for ((key, value) in entries) { + client.put("/put/$key") { setBody(value) } + } + val response = client.put("/getAll") { + setBody("""["aaa", "bbb", "ccc"]""") + } + + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"value": "value1", "key": "aaa"}, + {"key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + @Test + fun `multiple nonexistent keys can be retrieved`() = runApiTest { + val response = client.put("/getAll") { + setBody("['aaa', 'bbb', 'ccc']") + } + + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"key": "aaa"}, + {"key": "bbb"}, + {"key": "ccc"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + @Test + fun `multiple keys with some null values are stored correctly`() = runApiTest { + client.put("/putAll") { + //language=json + setBody( + """ + [ + {"value": "value1", "key": "aaa"}, + {"key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + ) + } + + val response = client.put("/getAll") { + setBody("""["aaa", "bbb", "ccc"]""") + } + + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"value": "value1", "key": "aaa"}, + {"key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + @Test + fun `multiple keys with some null values are recognized correctly`() = runApiTest { + val response = client.put("/putAll") { + //language=json + setBody( + """ + [ + {"value": "value1", "key": "aaa"}, + {"key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + ) + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("3 entries written", response.bodyAsText()) + } + + @Test + fun `multiple keys with non-null values are stored correctly`() = runApiTest { + client.put("/putAll") { + //language=json + setBody( + """ + [ + {"value": "value1", "key": "aaa"}, + {"value": "value2", "key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + ) + } + + val response = client.put("/getAll") { + setBody("""["aaa", "bbb", "ccc"]""") + } + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"value": "value1", "key": "aaa"}, + {"value": "value2", "key": "bbb"}, + {"value": "value3", "key": "ccc"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + @Test + fun `keys can be retrieved recursively`() = runApiTest { + client.put("/put/_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k") { + setBody("bar") + } + client.put("/put/existingKey") { + setBody("_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k") + } + val response = client.get("/getRecursively/existingKey") + + assertEquals(HttpStatusCode.OK, response.status) + //language=json + assertJsonEquals( + """ + [ + {"value": "_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k", "key": "existingKey"}, + {"key": "_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k", "value": "bar"} + ] + """.trimIndent(), + response.bodyAsText(), + ) + } + + private fun assertJsonEquals(expected: String, actual: String) { + assertEquals(JsonParser.parseString(expected), JsonParser.parseString(actual)) + } +} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt new file mode 100644 index 0000000000..2e4800a88c --- /dev/null +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024. + * + * 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 org.modelix.model.server.handlers + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.routing.routing +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.spyk +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import org.modelix.authorization.installAuthentication +import org.modelix.model.InMemoryModels +import org.modelix.model.server.installDefaultServerPlugins +import org.modelix.model.server.store.InMemoryStoreClient +import org.modelix.model.server.store.LocalModelClient +import org.modelix.model.server.store.forGlobalRepository +import kotlin.test.AfterTest +import kotlin.test.assertEquals + +class HealthApiTest { + private val inMemoryModels = InMemoryModels() + private val store = InMemoryStoreClient().forGlobalRepository() + private val localModelClient = LocalModelClient(store) + private val repositoriesManager = RepositoriesManager(localModelClient) + private val healthApi = HealthApiImpl(repositoriesManager, localModelClient.store, inMemoryModels) + private val healthApiSpy = spyk(healthApi, recordPrivateCalls = true) + + private fun runApiTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + installAuthentication(unitTestMode = true) + installDefaultServerPlugins() + routing { + healthApiSpy.installRoutes(this) + } + } + + block() + } + + @AfterTest + fun resetSpyK() { + clearMocks(healthApiSpy) + } + + @Test + fun `health endpoint returns healthy`() = runApiTest { + val response = client.get("/health") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("healthy", response.bodyAsText()) + } + + @Test + fun `health endpoint returns not healthy if unhealthy`() = runApiTest { + every { healthApiSpy["isHealthy"]() } returns false + val response = client.get("/health") + assertEquals(HttpStatusCode.InternalServerError, response.status) + + val expectedProblem = HttpException(HttpStatusCode.InternalServerError, details = "not healthy").problem + assertEquals(expectedProblem, Json.decodeFromString(response.bodyAsText())) + } +} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt new file mode 100644 index 0000000000..353449ed30 --- /dev/null +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024. + * + * 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 org.modelix.model.server.handlers.ui + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import org.modelix.authorization.installAuthentication +import org.modelix.model.client.successful +import org.modelix.model.server.installDefaultServerPlugins +import kotlin.test.Test +import kotlin.test.assertTrue + +class IndexPageTest { + + private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + installAuthentication(unitTestMode = true) + installDefaultServerPlugins() + IndexPage().init(this) + } + + block() + } + + @Test + fun `index page is reachable`() = runTest { + val response = client.get("/") + + assertTrue { response.successful } + assertTrue { response.bodyAsText().contains("Model Server") } + } +} diff --git a/model-server/src/test/resources/functionaltests/basic_routes.feature b/model-server/src/test/resources/functionaltests/basic_routes.feature deleted file mode 100644 index 8bd168575d..0000000000 --- a/model-server/src/test/resources/functionaltests/basic_routes.feature +++ /dev/null @@ -1,14 +0,0 @@ -Feature: Basic routes - We verify some basic routes work - - Scenario: Homepage works - Given the server has been started with in-memory storage - When I visit "/" - Then I should get an OK response - And the text of the page should contain "Model Server" - - Scenario: Heartbeat works - Given the server has been started with in-memory storage - When I visit "/health" - Then I should get an OK response - And the text of the page should be "healthy" diff --git a/model-server/src/test/resources/functionaltests/counter.feature b/model-server/src/test/resources/functionaltests/counter.feature deleted file mode 100644 index 14611c3d75..0000000000 --- a/model-server/src/test/resources/functionaltests/counter.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: Storing routes - We verify the core storing routes work - - Scenario: We should get different IDs for the same key - Given the server has been started with in-memory storage - When I POST "/counter/a" - And I should get an OK response - And I POST "/counter/a" - Then the text of the page should be different than before - -# Scenario: We should get different IDs for different keys -# Given the server has been started with in-memory storage -# When I POST "/counter/a" -# And I should get an OK response -# And I POST "/counter/b" -# Then the text of the page should be different than before diff --git a/model-server/src/test/resources/functionaltests/storing.feature b/model-server/src/test/resources/functionaltests/storing.feature deleted file mode 100644 index bed82d6872..0000000000 --- a/model-server/src/test/resources/functionaltests/storing.feature +++ /dev/null @@ -1,70 +0,0 @@ -Feature: Storing routes - We verify the core storing routes work - - Scenario: Storing and retrieving - Given the server has been started with in-memory storage - When I PUT on "/put/abc" the value "qwerty6789" - And I visit "/get/abc" - Then I should get an OK response - And the text of the page should be "qwerty6789" - - Scenario: Retrieving unexisting key - Given the server has been started with in-memory storage - When I visit "/get/abc" - Then I should get a NOT FOUND response - - Scenario: Retrieving forbidden key - Given the server has been started with in-memory storage - When I visit "/get/$$$_abc" - Then I should get a FORBIDDEN response - - Scenario: Retrieving multiple keys, all existing - Given the server has been started with in-memory storage - When I PUT on "/put/aaa" the value "value1" - And I PUT on "/put/bbb" the value "value2" - And I PUT on "/put/ccc" the value "value3" - And I PUT on "/getAll" the value "['aaa', 'bbb', 'ccc']" - Then I should get an OK response - And the text of the page should be this JSON "[{'value': 'value1', 'key': 'aaa'}, {'value': 'value2', 'key': 'bbb'}, {'value': 'value3', 'key': 'ccc'}]" - - Scenario: Retrieving multiple keys, some existing - Given the server has been started with in-memory storage - When I PUT on "/put/aaa" the value "value1" - And I PUT on "/put/ccc" the value "value3" - And I PUT on "/getAll" the value "['aaa', 'bbb', 'ccc']" - Then I should get an OK response - And the text of the page should be this JSON "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb'}, {'value': 'value3', 'key': 'ccc'}]" - - Scenario: Retrieving multiple keys, none existing - Given the server has been started with in-memory storage - When I PUT on "/getAll" the value "['aaa', 'bbb', 'ccc']" - Then I should get an OK response - And the text of the page should be this JSON "[{'key': 'aaa'}, {'key': 'bbb'}, {'key': 'ccc'}]" - - Scenario: Putting multiple keys, with some nulls, are stored correctly - Given the server has been started with in-memory storage - When I PUT on "/putAll" the value "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb'}, {'value': 'value3', 'key': 'ccc'}]" - And I PUT on "/getAll" the value "['aaa', 'bbb', 'ccc']" - Then I should get an OK response - And the text of the page should be this JSON "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb'}, {'value': 'value3', 'key': 'ccc'}]" - - Scenario: Putting multiple keys, with some nulls, are recognized correctly - Given the server has been started with in-memory storage - When I PUT on "/putAll" the value "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb'}, {'value': 'value3', 'key': 'ccc'}]" - Then I should get an OK response - And the text of the page should be "3 entries written" - - Scenario: Putting multiple keys - Given the server has been started with in-memory storage - When I PUT on "/putAll" the value "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb', 'value': 'value2'}, {'value': 'value3', 'key': 'ccc'}]" - And I PUT on "/getAll" the value "['aaa', 'bbb', 'ccc']" - Then I should get an OK response - And the text of the page should be this JSON "[{'value': 'value1', 'key': 'aaa'}, {'key': 'bbb', 'value': 'value2'}, {'value': 'value3', 'key': 'ccc'}]" - - Scenario: Get recursively - Given the server has been started with in-memory storage - And I PUT on "/put/_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k" the value "bar" - And I PUT on "/put/existingKey" the value "_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k" - When I visit "/getRecursively/existingKey" - Then I should get an OK response - And the text of the page should be this JSON "[{'value': '_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k', 'key': 'existingKey'}, {'key': '_N4rL*tula_QIYB-3If6bXDONEO5CnqBPrlURto-_j7k', 'value': 'bar'}]" diff --git a/model-server/src/test/resources/functionaltests/subscription.feature b/model-server/src/test/resources/functionaltests/subscription.feature deleted file mode 100644 index b144b3bc2d..0000000000 --- a/model-server/src/test/resources/functionaltests/subscription.feature +++ /dev/null @@ -1,14 +0,0 @@ -Feature: Storing routes - We verify the core storing routes work - - Scenario: Events are received after subscription - Given the server has been started with in-memory storage - When I long poll "/poll/dylandog" - And I PUT on "/put/dylandog" the value "a comic book" - Then Long poll should return "a comic book" - - Scenario: Events are received only for the key subscribed - Given the server has been started with in-memory storage - When I long poll "/poll/dylandog" - And I PUT on "/put/topolino" the value "a comic book" - Then Long poll should NOT return "a comic book" diff --git a/model-server/src/test/resources/functionaltests/token.feature b/model-server/src/test/resources/functionaltests/token.feature deleted file mode 100644 index d107b5b37b..0000000000 --- a/model-server/src/test/resources/functionaltests/token.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: Storing routes - We verify the core storing routes work - -# Scenario: A token can be generated -# Given the server has been started with in-memory storage -# When I visit "/generateToken" -# Then I should get an OK response -# And the text of the page should be 32 characters long -# And the text of the page contains only hexadecimal digits diff --git a/model-server/src/test/resources/functionaltests/userinfo.feature b/model-server/src/test/resources/functionaltests/userinfo.feature deleted file mode 100644 index 7d516109b2..0000000000 --- a/model-server/src/test/resources/functionaltests/userinfo.feature +++ /dev/null @@ -1,35 +0,0 @@ -Feature: Basic routes - We verify some basic routes work - -# Scenario: No email to get when not logged -# Given the server has been started with in-memory storage -# When I visit "/getEmail" -# Then I should get an NO CONTENT response -# And the text of the page should be 0 characters long -# -# Scenario: No email to get when logged -# # the token should expire in year 2286 -# Given the server has been started with in-memory storage loaded with "$$$_token_expires_mySpectacularToken=9999999999999,$$$_token_email_mySpectacularToken=cool@mail.com" -# When I visit "/getEmail" with header "Authorization" set to "Bearer mySpectacularToken" -# Then I should get an OK response -# And the text of the page should be "cool@mail.com" -# -# Scenario: Default email after token is generated -# Given the server has been started with in-memory storage -# And I visit "/generateToken" -# When I visit "/getEmail" with header "Authorization" set to "Bearer #TEXT_OF_LAST_PAGE#" -# Then I should get an OK response -# And the text of the page should be "localhost" -# -# Scenario: Get correct email after token is generated with email -# Given the server has been started with in-memory storage -# And I visit "/generateToken" with headers "X-Forwarded-Email=my@email.com" -# When I visit "/getEmail" with headers "Authorization=Bearer #TEXT_OF_LAST_PAGE#" -# Then I should get an OK response -# And the text of the page should be "my@email.com" - - Scenario: Default email after token is generated - Given the server has been started with in-memory storage - When I visit "/getEmail" - Then I should get an OK response - And the text of the page should be "unit-tests@example.com" From c473039a4a8500600b2bd6d3400886e5c2a156b9 Mon Sep 17 00:00:00 2001 From: Michael Huster Date: Tue, 2 Jul 2024 15:29:47 +0200 Subject: [PATCH 3/3] build(model-server): remove cucumber dependencies --- gradle/libs.versions.toml | 1 - model-server/build.gradle.kts | 28 ---------------------------- 2 files changed, 29 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5096297f55..4b8a4ffc87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,7 +106,6 @@ ignite-indexing = { group = "org.apache.ignite", name = "ignite-indexing", versi logback-classic = { group = "ch.qos.logback", name = "logback-classic", version = "1.5.6" } postgresql = { group = "org.postgresql", name = "postgresql", version = "42.7.3" } jcommander = { group = "com.beust", name = "jcommander", version = "1.82" } -cucumber-java = { group = "io.cucumber", name = "cucumber-java", version = "7.18.0" } junit = { group = "junit", name = "junit", version = "4.13.2" } xmlunit-core = { group = "org.xmlunit", name = "xmlunit-core", version.ref="xmlunit"} xmlunit-matchers = { group = "org.xmlunit", name = "xmlunit-matchers", version.ref="xmlunit"} diff --git a/model-server/build.gradle.kts b/model-server/build.gradle.kts index 318b3ab661..4ebcf4efa3 100644 --- a/model-server/build.gradle.kts +++ b/model-server/build.gradle.kts @@ -68,7 +68,6 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotest.assertions.coreJvm) testImplementation(libs.kotest.assertions.ktor) - testImplementation(libs.cucumber.java) testImplementation(libs.ktor.server.test.host) testImplementation(libs.kotlin.coroutines.test) testImplementation(libs.jsoup) @@ -84,10 +83,6 @@ tasks.test { useJUnitPlatform() } -val cucumberRuntime by configurations.creating { - extendsFrom(configurations["testImplementation"]) -} - tasks.named("shadowJar") { archiveBaseName.set("model-server") archiveClassifier.set("fatJar") @@ -113,28 +108,6 @@ val fatJarArtifact = artifacts.add("archives", fatJarFile) { builtBy("shadowJar") } -val cucumber = task("cucumber") { - dependsOn("shadowJar", "compileTestJava") - doLast { - javaexec { - mainClass.set("io.cucumber.core.cli.Main") - classpath = cucumberRuntime + sourceSets.main.get().output + sourceSets.test.get().output - args = listOf( - "--plugin", - "pretty", - // Enable junit reporting so that GitHub actions can report on these tests, too - "--plugin", - "junit:${project.layout.buildDirectory.dir("test-results/cucumber.xml").get()}", - // Change glue for your project package where the step definitions are. - "--glue", - "org.modelix.model.server.functionaltests", - // Specify where the feature files are. - "src/test/resources/functionaltests", - ) - } - } -} - // copies the openAPI specifications from the api folder into a resource // folder so that they are packaged and deployed with the model-server tasks.register("copyApis") { @@ -149,7 +122,6 @@ tasks.named("compileKotlin") { } tasks.named("build") { - dependsOn("cucumber") dependsOn("copyApis") }