diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 535212b..40a270a 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -27,12 +27,18 @@ jobs:
restore-keys: "${{ runner.os }}-maven-"
- name: Install SDKMAN
run: curl -s "https://get.sdkman.io?rcupdate=false" | bash
- - name: Build Application
+ - name: Mutation Test Application
run: |
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk env install
./mvnw -P pitest
+ - name: Load Test Application
+ run: |
+ source "$HOME/.sdkman/bin/sdkman-init.sh"
+ sdk env install
+
+ ./mvnw -P gatling
- name: Build Client
run: |
export CLIENT_PATH='target/generated-sources/openapi'
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6cc60ef..c5e2beb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -96,4 +96,6 @@ nightly:
- source "$HOME/.sdkman/bin/sdkman-init.sh"
script:
- sdk env install
- - ./mvnw -P pitest
+ # TODO Enable ITs when nightly tests are fixed
+ - ./mvnw -P pitest -D skipITs
+ - ./mvnw -P gatling -D skipITs
diff --git a/.mvn/parent.xml b/.mvn/parent.xml
index e859bb9..852ac55 100644
--- a/.mvn/parent.xml
+++ b/.mvn/parent.xml
@@ -14,7 +14,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.3.3
+ 3.3.4
@@ -34,7 +34,6 @@
${openapi.package}.client
${project.groupId}/${project.artifactId}
docker.io
- verify
undertow
ui
@@ -44,6 +43,8 @@
0.8.12
1.3.0
+ 3.12.0
+ 4.9.6
@@ -103,6 +104,12 @@
kafka
test
+
+ io.gatling.highcharts
+ gatling-charts-highcharts
+ ${gatling.version}
+ test
+
@@ -342,5 +349,69 @@
+
+
+ gatling
+
+
+ post-integration-test
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.4.1
+
+
+ docker-up
+
+ exec
+
+ pre-integration-test
+
+ docker
+
+ compose
+ --profile=local
+ up
+ -d
+
+
+
+
+ docker-down
+
+ exec
+
+ post-integration-test
+
+ docker
+
+ compose
+ --profile=local
+ rm
+ -sfv
+
+
+
+
+
+
+
+ io.gatling
+ gatling-maven-plugin
+ ${gatling.maven.version}
+
+
+
+ test
+
+ integration-test
+
+
+
+
+
+
diff --git a/LICENSE.md b/LICENSE.md
index 927e7fd..564bab9 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+## Third Party Libraries Licenses
+For the list of dependencies' licenses, check the Maven dependencies report.
diff --git a/README.md b/README.md
index e5fc028..c9e71f0 100644
--- a/README.md
+++ b/README.md
@@ -125,3 +125,15 @@ The verification requests can be executed with: `src/test/resources/requests.sh`
`PORT=9090 src/test/resources/requests.sh` if you want to run them to a different port.
The health check endpoint is: http://localhost:18080/actuator/health
+
+### Stress Testing (Gatling)
+[Gatling settings] can be overridden creating a `gatling.conf` file at the test resources. The
+configuration options and their default values can be checked [here][gatlingDefaults].
+
+Those parameters can also be overwritten by system properties from the command line. I.e.:
+`-D gatling.core.encoding=utf-8`
+
+To run the Gatling test, execute `./mvnw gatling:test` at the shell.
+
+[Gatling settings]: https://docs.gatling.io/reference/script/core/configuration
+[gatlingDefaults]: https://github.com/gatling/gatling/blob/main/gatling-core/src/main/resources/gatling-defaults.conf
diff --git a/docker-compose.yml b/docker-compose.yml
index add5061..b366149 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,7 +16,7 @@ services:
- "15432:5432"
kafka:
- image: docker.io/apache/kafka:3.7.0
+ image: docker.io/apache/kafka-native:3.8.0
environment:
CLUSTER_ID: 4L6g3nShT-eMCtK--X86sw
KAFKA_NODE_ID: 1
diff --git a/pom.xml b/pom.xml
index 973acc1..0b7e91c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
appointments
- 0.3.7
+ 0.3.8
Appointments
Application to create appointments (REST API)
@@ -28,7 +28,9 @@
${openapi.package}.client
undertow
ui
- deploy
+
+ com.github.jaguililla.appointments.GatlingSimulation
+
diff --git a/set_up.java b/set_up.java
new file mode 100755
index 0000000..d521f3d
--- /dev/null
+++ b/set_up.java
@@ -0,0 +1,28 @@
+///usr/bin/env java --enable-preview --source 21 -cp "*" "$0" "$@" ; exit $?
+
+import static java.util.Map.entry;
+
+import java.io.Console;
+import java.util.List;
+
+private Console console = System.console();
+
+void main() {
+ var options = List.of(
+ entry("GitHub", prompt("Keep GitHub workflows and templates: (Yn)", "y")),
+ entry("GitLab", prompt("Keep GitLab workflows and templates: (Yn)", "y")),
+ entry("GitLab", prompt("Keep 'CODE_OF_CONDUCT.md' file: (Yn)", "y")),
+ entry("GitLab", prompt("Keep 'CONTRIBUTING.md' file: (Yn)", "y")),
+ entry("GitLab", prompt("Keep 'LICENSE.md' file: (Yn)", "y")),
+ entry("GitLab", prompt("Keep this set up file 'set_up.java' file: (Yn)", "y"))
+ );
+
+ // Rename artifacts, groups or base package
+ // Restart Git history (or delete it)
+}
+
+public String prompt(String message, String defaultValue) {
+ console.printf(message);
+ var v = console.readLine();
+ return v == null || v.isBlank() ? defaultValue : v;
+}
diff --git a/src/main/java/com/github/jaguililla/appointments/input/controllers/AppointmentsController.java b/src/main/java/com/github/jaguililla/appointments/input/controllers/AppointmentsController.java
index 96fcdb3..21a6a2c 100644
--- a/src/main/java/com/github/jaguililla/appointments/input/controllers/AppointmentsController.java
+++ b/src/main/java/com/github/jaguililla/appointments/input/controllers/AppointmentsController.java
@@ -36,7 +36,7 @@ public ResponseEntity createAppointment(
final var createdAppointment = appointmentsService.create(appointment, users);
final var responseAppointment = AppointmentsMapper.appointmentResponse(createdAppointment);
- return ResponseEntity.ofNullable(responseAppointment);
+ return ResponseEntity.status(201).body(responseAppointment);
}
@Override
diff --git a/src/main/java/com/github/jaguililla/appointments/input/controllers/UsersController.java b/src/main/java/com/github/jaguililla/appointments/input/controllers/UsersController.java
index 7b5704b..963d017 100644
--- a/src/main/java/com/github/jaguililla/appointments/input/controllers/UsersController.java
+++ b/src/main/java/com/github/jaguililla/appointments/input/controllers/UsersController.java
@@ -30,6 +30,6 @@ public ResponseEntity createUser(final UserRequest userRequest) {
final var createdUser = appointmentsService.create(user);
final var responseUser = UsersMapper.userResponse(createdUser);
- return ResponseEntity.ofNullable(responseUser);
+ return ResponseEntity.status(201).body(responseUser);
}
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ef32648..ae9ee53 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -13,6 +13,7 @@ management.endpoints.jmx.exposure.exclude: "*"
spring:
threads.virtual.enabled: true
+
datasource:
url: ${JDBC_URL:jdbc:postgresql://localhost:15432/local}
username: ${JDBC_USERNAME:root}
diff --git a/src/main/resources/openapi/api.yml b/src/main/resources/openapi/api.yml
index 04b7e73..66f9707 100644
--- a/src/main/resources/openapi/api.yml
+++ b/src/main/resources/openapi/api.yml
@@ -19,7 +19,7 @@ paths:
schema:
$ref: 'schemas.yml#/components/schemas/UserRequest'
responses:
- "200":
+ "201":
description: OK
content:
application/json:
@@ -36,7 +36,7 @@ paths:
schema:
$ref: 'schemas.yml#/components/schemas/AppointmentRequest'
responses:
- "200":
+ "201":
description: OK
content:
application/json:
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
new file mode 100644
index 0000000..68a7498
--- /dev/null
+++ b/src/main/resources/static/index.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ Appointments
+
+
+
+
+
+
+
+Open
+
+
+
+ Id |
+ Title |
+ Order |
+
+
+
+
+
+New
+
+
+
+
+
+ title |
+ 0 |
+ |
+
+
+
+
diff --git a/src/main/resources/static/main.js b/src/main/resources/static/main.js
new file mode 100644
index 0000000..acc73f6
--- /dev/null
+++ b/src/main/resources/static/main.js
@@ -0,0 +1,62 @@
+
+const http = new XMLHttpRequest();
+
+const tbody = document.querySelector("tbody#appointments");
+const template = document.querySelector("#appointmentRow");
+const titleInput = document.querySelector("#title");
+const orderInput = document.querySelector("#order");
+
+function httpSend(method, url, body, callback) {
+ http.open(method, url);
+ http.setRequestHeader('Content-type', 'application/json');
+ http.send(JSON.stringify(body));
+ http.onload = callback;
+}
+
+function httpGet(url, body, callback) {
+ httpSend('GET', url, body, callback);
+}
+
+function httpPost(url, body, callback) {
+ httpSend('POST', url, body, callback);
+}
+
+function addTask(task) {
+ const clone = template.content.cloneNode(true);
+ const td = clone.querySelectorAll("td");
+ const input = clone.querySelectorAll("input");
+ td[0].textContent = task.title;
+ td[1].textContent = task.order;
+ input.checked = task.completed;
+ tbody.appendChild(clone);
+}
+
+function add() {
+ const body = {
+ title: titleInput.value,
+ order: orderInput.valueAsNumber
+ };
+
+ httpPost('/appointments', body, () => {
+ titleInput.value = "";
+ orderInput.value = 0;
+
+ addTask(JSON.parse(http.responseText));
+ });
+}
+
+function main() {
+
+ httpGet('/appointments', null, () => {
+ for (const tr of tbody.children)
+ tr.remove();
+
+ const response = JSON.parse(http.responseText);
+ for (const task of response)
+ addTask(task);
+
+ console.log(http.responseText);
+ });
+}
+
+document.body.onload = main;
diff --git a/src/main/resources/static/simple.css b/src/main/resources/static/simple.css
new file mode 100644
index 0000000..c35f273
--- /dev/null
+++ b/src/main/resources/static/simple.css
@@ -0,0 +1,749 @@
+
+/* Global variables. */
+:root,
+::backdrop {
+ --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, "Nimbus Sans L", Roboto,
+ "Noto Sans", "Segoe UI", Arial, Helvetica, "Helvetica Neue", sans-serif;
+ --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
+
+ --small-font-size: 0.9rem;
+
+ --body-font-size: 1.15rem;
+ --nav-font-size: 1rem;
+ --aside-font-size: 1rem;
+ --footer-font-size: var(--small-font-size);
+ --figcaption-font-size: var(--small-font-size);
+ --cite-font-size: var(--small-font-size);
+
+ --nav-line-height: 2;
+ --body-line-height: 1.5;
+ --h123-line-height: 1.1;
+ --nav-mobile-line-height: 1;
+
+ --standard-border-radius: 5px;
+
+ /* Default (light) theme */
+ --bg: #fff;
+ --accent-bg: #f5f7ff;
+ --text: #212121;
+ --text-light: #585858;
+ --border: #898EA4;
+ --accent: #0d47a1;
+ --accent-hover: #1266e2;
+ --accent-text: var(--bg);
+ --code: #d81b60;
+ --preformatted: #444;
+ --marked: #ffdd33;
+ --disabled: #efefef;
+}
+
+/* Dark theme */
+@media (prefers-color-scheme: dark) {
+ :root,
+ ::backdrop {
+ color-scheme: dark;
+
+ --bg: #212121;
+ --accent-bg: #2b2b2b;
+ --text: #dcdcdc;
+ --text-light: #ababab;
+ --accent: #ffb300;
+ --accent-hover: #ffe099;
+ --accent-text: var(--bg);
+ --code: #f06292;
+ --preformatted: #ccc;
+ --disabled: #111;
+ }
+
+ /* Add a bit of transparency so light media isn't so glaring in dark mode */
+ img,
+ video {
+ opacity: 0.8;
+ }
+}
+
+/* Reset box-sizing */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* Reset default appearance */
+textarea,
+select,
+input,
+progress {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+}
+
+html {
+ /* Set the font globally */
+ font-family: var(--sans-font), sans-serif;
+ scroll-behavior: smooth;
+}
+
+/* Make the body a nice central block */
+body {
+ color: var(--text);
+ background-color: var(--bg);
+ font-size: var(--body-font-size);
+ line-height: var(--body-line-height);
+ display: grid;
+ grid-template-columns: 1fr min(45rem, 90%) 1fr;
+ margin: 0;
+}
+
+body > * {
+ grid-column: 2;
+}
+
+/* Make the header bg full width, but the content inline with body */
+body > header {
+ background-color: var(--accent-bg);
+ border-bottom: 1px solid var(--border);
+ text-align: center;
+ padding: 0 0.5rem 2rem 0.5rem;
+ grid-column: 1 / -1;
+}
+
+body > header > *:only-child {
+ margin-block-start: 2rem;
+}
+
+body > header h1 {
+ max-width: 1200px;
+ margin: 1rem auto;
+}
+
+body > header p {
+ max-width: 40rem;
+ margin: 1rem auto;
+}
+
+/* Add a little padding to ensure spacing is correct between content and header > nav */
+main {
+ padding-top: 1.5rem;
+}
+
+body > footer {
+ margin-top: 4rem;
+ padding: 2rem 1rem 1.5rem 1rem;
+ color: var(--text-light);
+ font-size: var(--footer-font-size);
+ text-align: center;
+ border-top: 1px solid var(--border);
+}
+
+/* Format headers */
+h1 {
+ font-size: 3rem;
+}
+
+h2 {
+ font-size: 2.6rem;
+ margin-top: 3rem;
+}
+
+h3 {
+ font-size: 2rem;
+ margin-top: 3rem;
+}
+
+h4 {
+ font-size: 1.44rem;
+}
+
+h5 {
+ font-size: 1.15rem;
+}
+
+h6 {
+ font-size: 0.96rem;
+}
+
+p {
+ margin: 1.5rem 0;
+}
+
+/* Prevent long strings from overflowing container */
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ overflow-wrap: break-word;
+}
+
+/* Fix line height when title wraps */
+h1,
+h2,
+h3 {
+ line-height: var(--h123-line-height);
+}
+
+/* Reduce header size on mobile */
+@media only screen and (max-width: 720px) {
+ h1 {
+ font-size: 2.5rem;
+ }
+
+ h2 {
+ font-size: 2.1rem;
+ }
+
+ h3 {
+ font-size: 1.75rem;
+ }
+
+ h4 {
+ font-size: 1.25rem;
+ }
+}
+
+/* Format links & buttons */
+a,
+a:visited {
+ color: var(--accent);
+}
+
+a:hover {
+ text-decoration: none;
+}
+
+button,
+.button,
+a.button, /* extra specificity to override a */
+input[type="submit"],
+input[type="reset"],
+input[type="button"],
+label[type="button"] {
+ border: 1px solid var(--accent);
+ background-color: var(--accent);
+ color: var(--accent-text);
+ padding: 0.5rem 0.9rem;
+ text-decoration: none;
+ line-height: normal;
+}
+
+.button[aria-disabled="true"],
+input:disabled,
+textarea:disabled,
+select:disabled,
+button[disabled] {
+ cursor: not-allowed;
+ background-color: var(--disabled);
+ border-color: var(--disabled);
+ color: var(--text-light);
+}
+
+input[type="range"] {
+ padding: 0;
+}
+
+/* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */
+abbr[title] {
+ cursor: help;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+}
+
+button:enabled:hover,
+.button:not([aria-disabled="true"]):hover,
+input[type="submit"]:enabled:hover,
+input[type="reset"]:enabled:hover,
+input[type="button"]:enabled:hover,
+label[type="button"]:hover {
+ background-color: var(--accent-hover);
+ border-color: var(--accent-hover);
+ cursor: pointer;
+}
+
+.button:focus-visible,
+button:focus-visible:where(:enabled),
+input:enabled:focus-visible:where(
+ [type="submit"],
+ [type="reset"],
+ [type="button"]
+) {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Format navigation */
+header > nav {
+ font-size: var(--nav-font-size);
+ line-height: var(--nav-line-height);
+ padding: 1rem 0 0 0;
+}
+
+/* Use flexbox to allow items to wrap, as needed */
+header > nav ul,
+header > nav ol {
+ align-content: space-around;
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+/* List items are inline elements, make them behave more like blocks */
+header > nav ul li,
+header > nav ol li {
+ display: inline-block;
+}
+
+header > nav a,
+header > nav a:visited {
+ margin: 0 0.5rem 1rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ color: var(--text);
+ display: inline-block;
+ padding: 0.1rem 1rem;
+ text-decoration: none;
+}
+
+header > nav a:hover,
+header > nav a.current,
+header > nav a[aria-current="page"],
+header > nav a[aria-current="true"] {
+ border-color: var(--accent);
+ color: var(--accent);
+ cursor: pointer;
+}
+
+/* Reduce nav side on mobile */
+@media only screen and (max-width: 720px) {
+ header > nav a {
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ line-height: var(--nav-mobile-line-height);
+ }
+}
+
+/* Consolidate box styling */
+aside,
+details,
+pre,
+progress {
+ background-color: var(--accent-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ margin-bottom: 1rem;
+}
+
+aside {
+ font-size: var(--aside-font-size);
+ width: 30%;
+ padding: 0 15px;
+ margin-inline-start: 15px;
+ float: right;
+}
+
+*[dir="rtl"] aside {
+ float: left;
+}
+
+/* Make aside full-width on mobile */
+@media only screen and (max-width: 720px) {
+ aside {
+ width: 100%;
+ float: none;
+ margin-inline-start: 0;
+ }
+}
+
+article,
+fieldset,
+dialog {
+ border: 1px solid var(--border);
+ padding: 1rem;
+ border-radius: var(--standard-border-radius);
+ margin-bottom: 1rem;
+}
+
+article h2:first-child,
+section h2:first-child,
+article h3:first-child,
+section h3:first-child {
+ margin-top: 1rem;
+}
+
+section {
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ padding: 2rem 1rem;
+ margin: 3rem 0;
+}
+
+/* Don't double separators when chaining sections */
+section + section,
+section:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+section + section {
+ margin-top: 0;
+}
+
+section:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+details {
+ padding: 0.7rem 1rem;
+}
+
+summary {
+ cursor: pointer;
+ font-weight: bold;
+ padding: 0.7rem 1rem;
+ margin: -0.7rem -1rem;
+ word-break: break-all;
+}
+
+details[open] > summary + * {
+ margin-top: 0;
+}
+
+details[open] > summary {
+ margin-bottom: 0.5rem;
+}
+
+details[open] > :last-child {
+ margin-bottom: 0;
+}
+
+/* Format tables */
+table {
+ border-collapse: collapse;
+ margin: 1.5rem 0;
+}
+
+figure > table {
+ width: max-content;
+ margin: 0;
+}
+
+td,
+th {
+ border: 1px solid var(--border);
+ text-align: start;
+ padding: 0.5rem;
+}
+
+th {
+ background-color: var(--accent-bg);
+ font-weight: bold;
+}
+
+tr:nth-child(even) {
+ /* Set every other cell slightly darker. Improves readability. */
+ background-color: var(--accent-bg);
+}
+
+table caption {
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
+
+/* Format forms */
+textarea,
+select,
+input,
+button,
+.button {
+ font-size: inherit;
+ font-family: inherit;
+ padding: 0.5rem;
+ margin-bottom: 0.5rem;
+ border-radius: var(--standard-border-radius);
+ box-shadow: none;
+ max-width: 100%;
+ display: inline-block;
+}
+
+textarea,
+select,
+input {
+ color: var(--text);
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+}
+
+label {
+ display: block;
+}
+
+textarea:not([cols]) {
+ width: 100%;
+}
+
+/* Add arrow to drop-down */
+select:not([multiple]) {
+ background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
+ linear-gradient(135deg, var(--text) 51%, transparent 49%);
+ background-position: calc(100% - 15px), calc(100% - 10px);
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ padding-inline-end: 25px;
+}
+
+*[dir="rtl"] select:not([multiple]) {
+ background-position: 10px, 15px;
+}
+
+/* checkbox and radio button style */
+input[type="checkbox"],
+input[type="radio"] {
+ vertical-align: middle;
+ position: relative;
+ width: min-content;
+}
+
+input[type="checkbox"] + label,
+input[type="radio"] + label {
+ display: inline-block;
+}
+
+input[type="radio"] {
+ border-radius: 100%;
+}
+
+input[type="checkbox"]:checked,
+input[type="radio"]:checked {
+ background-color: var(--accent);
+}
+
+input[type="checkbox"]:checked::after {
+ /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
+ content: " ";
+ width: 0.18em;
+ height: 0.32em;
+ border-radius: 0;
+ position: absolute;
+ top: 0.05em;
+ left: 0.17em;
+ background-color: transparent;
+ border-right: solid var(--bg) 0.08em;
+ border-bottom: solid var(--bg) 0.08em;
+ font-size: 1.8em;
+ transform: rotate(45deg);
+}
+
+input[type="radio"]:checked::after {
+ /* creates a colored circle for the checked radio button */
+ content: " ";
+ width: 0.25em;
+ height: 0.25em;
+ border-radius: 100%;
+ position: absolute;
+ top: 0.125em;
+ background-color: var(--bg);
+ left: 0.125em;
+ font-size: 32px;
+}
+
+/* Makes input fields wider on smaller screens */
+@media only screen and (max-width: 720px) {
+ textarea,
+ select,
+ input {
+ width: 100%;
+ }
+}
+
+/* Set a height for color input */
+input[type="color"] {
+ height: 2.5rem;
+ padding: 0.2rem;
+}
+
+/* do not show border around file selector button */
+input[type="file"] {
+ border: 0;
+}
+
+/* Misc body elements */
+hr {
+ border: none;
+ height: 1px;
+ background: var(--border);
+ margin: 1rem auto;
+}
+
+mark {
+ padding: 2px 5px;
+ border-radius: var(--standard-border-radius);
+ background-color: var(--marked);
+ color: black;
+}
+
+mark a {
+ color: #0d47a1;
+}
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--standard-border-radius);
+}
+
+figure {
+ margin: 0;
+ display: block;
+ overflow-x: auto;
+}
+
+figure > img,
+figure > picture > img {
+ display: block;
+ margin-inline: auto;
+}
+
+figcaption {
+ text-align: center;
+ font-size: var(--figcaption-font-size);
+ color: var(--text-light);
+ margin-block: 1rem;
+}
+
+blockquote {
+ margin-inline-start: 2rem;
+ margin-inline-end: 0;
+ margin-block: 2rem;
+ padding: 0.4rem 0.8rem;
+ border-inline-start: 0.35rem solid var(--accent);
+ color: var(--text-light);
+ font-style: italic;
+}
+
+cite {
+ font-size: var(--cite-font-size);
+ color: var(--text-light);
+ font-style: normal;
+}
+
+dt {
+ color: var(--text-light);
+}
+
+/* Use mono font for code elements */
+code,
+pre,
+pre span,
+kbd,
+samp {
+ font-family: var(--mono-font), monospace;
+ color: var(--code);
+}
+
+kbd {
+ color: var(--preformatted);
+ border: 1px solid var(--preformatted);
+ border-bottom: 3px solid var(--preformatted);
+ border-radius: var(--standard-border-radius);
+ padding: 0.1rem 0.4rem;
+}
+
+pre {
+ padding: 1rem 1.4rem;
+ max-width: 100%;
+ overflow: auto;
+ color: var(--preformatted);
+}
+
+/* Fix embedded code within pre */
+pre code {
+ color: var(--preformatted);
+ background: none;
+ margin: 0;
+ padding: 0;
+}
+
+/* Progress bars */
+/* Declarations are repeated because you */
+/* cannot combine vendor-specific selectors */
+progress {
+ width: 100%;
+}
+
+progress:indeterminate {
+ background-color: var(--accent-bg);
+}
+
+progress::-webkit-progress-bar {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent-bg);
+}
+
+progress::-webkit-progress-value {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent);
+}
+
+progress::-moz-progress-bar {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent);
+ transition-property: width;
+ transition-duration: 0.3s;
+}
+
+progress:indeterminate::-moz-progress-bar {
+ background-color: var(--accent-bg);
+}
+
+dialog {
+ max-width: 40rem;
+ margin: auto;
+}
+
+dialog::backdrop {
+ background-color: var(--bg);
+ opacity: 0.8;
+}
+
+@media only screen and (max-width: 720px) {
+ dialog {
+ max-width: 100%;
+ margin: auto 1em;
+ }
+}
+
+/* Superscript & Subscript */
+/* Prevent scripts from affecting line-height. */
+sup,
+sub {
+ vertical-align: baseline;
+ position: relative;
+}
+
+sup {
+ top: -0.4em;
+}
+
+sub {
+ top: 0.3em;
+}
+
+/* Classes for notices */
+.notice {
+ background: var(--accent-bg);
+ border: 2px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ padding: 1.5rem;
+ margin: 2rem 0;
+}
diff --git a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
index a531975..df77bad 100644
--- a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
+++ b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
@@ -11,7 +11,10 @@
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@@ -28,10 +31,11 @@
classes = {Application.class},
webEnvironment = RANDOM_PORT
)
+@TestMethodOrder(OrderAnnotation.class)
class ApplicationIT {
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
- static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.7.0");
+ static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.8.0");
private final TestTemplate client;
@Autowired
@@ -61,6 +65,7 @@ static void configureProperties(final DynamicPropertyRegistry registry) {
}
@Test
+ @Order(1)
void specification_requests_work_as_expected() {
client.get("/v3/api-docs");
assertTrue(client.getResponseBody().contains("openapi"));
@@ -68,6 +73,7 @@ void specification_requests_work_as_expected() {
}
@Test
+ @Order(2)
void actuator_requests_work_as_expected() {
client.get("/actuator/health");
assertTrue(client.getResponseBody().contains("UP"));
@@ -75,6 +81,7 @@ void actuator_requests_work_as_expected() {
}
@Test
+ @Order(3)
void existing_appointments_can_be_fetched() {
client.get("/appointments");
var response = client.getResponseBody(AppointmentResponse[].class);
@@ -84,6 +91,7 @@ void existing_appointments_can_be_fetched() {
}
@Test
+ @Order(4)
void appointments_can_be_created_read_and_deleted() {
client.post("/appointments", new AppointmentRequest()
.id(UUID.randomUUID())
@@ -91,7 +99,7 @@ void appointments_can_be_created_read_and_deleted() {
.endTimestamp(LocalDateTime.now())
);
var response = client.getResponseBody(AppointmentResponse.class);
- assertEquals(200, client.getResponseStatus().value());
+ assertEquals(201, client.getResponseStatus().value());
assertTrue(getLastMessage().startsWith("Appointment created at"));
client.get("/appointments/" + response.getId());
assertEquals(200, client.getResponseStatus().value());
diff --git a/src/test/java/com/github/jaguililla/appointments/GatlingSimulation.java b/src/test/java/com/github/jaguililla/appointments/GatlingSimulation.java
new file mode 100644
index 0000000..f3a8723
--- /dev/null
+++ b/src/test/java/com/github/jaguililla/appointments/GatlingSimulation.java
@@ -0,0 +1,67 @@
+package com.github.jaguililla.appointments;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+import static java.lang.Integer.parseInt;
+import static java.lang.Long.parseLong;
+import static java.lang.System.getProperty;
+
+import io.gatling.javaapi.core.*;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+public class GatlingSimulation extends Simulation {
+
+ private static final long SCENARIO_SECONDS = parseLong(getProperty("scenario.seconds", "30"));
+ private static final int MAX_USERS = parseInt(getProperty("max.users", "20"));
+ private static final int USERS_RAMP_SECONDS = parseInt(getProperty("users.ramp.seconds", "5"));
+ private static final String BASE_URL = getProperty("base.url", "http://localhost:18080");
+
+ private final ChainBuilder appointmentsList = during(SCENARIO_SECONDS).on(
+ http("List")
+ .get("/appointments")
+ .check(status().is(200))
+ );
+
+ private final ChainBuilder appointmentsCrud = during(SCENARIO_SECONDS).on(
+ http("Create")
+ .post("/appointments")
+ .header("Content-Type", "application/json")
+ .body(StringBody("""
+ {
+ "id": "#{id}",
+ "startTimestamp": "2024-09-28T21:28:00",
+ "endTimestamp": "2024-09-28T21:28:00"
+ }
+ """
+ ))
+ .check(status().is(201))
+ .check(jmesPath("id").saveAs("id")),
+ http("Read")
+ .get("/appointments/#{id}")
+ .check(status().is(200)),
+ http("Delete")
+ .delete("/appointments/#{id}")
+ .check(status().is(200))
+ );
+
+ {
+ var httpProtocol = http.baseUrl(BASE_URL);
+ var feeder = Stream
+ .generate(UUID::randomUUID)
+ .map(UUID::toString)
+ .