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 + + + + + +
+

Appointments

+

Example task application

+ + +
+ +

Open

+ + + + + + + + + +
IdTitleOrder
+ +

New

+
+ + + + +
+ + + + + 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) + .>map(it -> Map.of("id", it)) + .iterator(); + + var crud = scenario("Appointments CRUD").feed(feeder).exec(appointmentsCrud); + var list = scenario("Appointments List").exec(appointmentsList); + + setUp( + crud.injectOpen(rampUsers(MAX_USERS).during(USERS_RAMP_SECONDS)), + list.injectOpen(rampUsers(MAX_USERS).during(USERS_RAMP_SECONDS)) + ) + .protocols(httpProtocol); + } +}