diff --git a/.gitignore b/.gitignore index 8823ae7..eb14443 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ **/*.jar **/target .idea/ +**/**/project.iml \ No newline at end of file diff --git a/edu-spring-postgres/backend/Dockerfile b/edu-spring-postgres/backend/Dockerfile index a941385..50b6db2 100755 --- a/edu-spring-postgres/backend/Dockerfile +++ b/edu-spring-postgres/backend/Dockerfile @@ -1,26 +1,47 @@ -# syntax=docker/dockerfile:1.4 +# syntax=docker/dockerfile:1.6 -FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS builder -WORKDIR /workdir/server -COPY pom.xml /workdir/server/pom.xml -RUN mvn dependency:go-offline +# Stage 1: Build +FROM maven:3.9.14-eclipse-temurin-25-alpine AS builder -COPY src /workdir/server/src -RUN mvn install +RUN apk upgrade --available --no-cache -FROM builder AS prepare-production -RUN mkdir -p target/dependency -WORKDIR /workdir/server/target/dependency -RUN jar -xf ../*.jar +WORKDIR /app -FROM eclipse-temurin:21-jre-noble +COPY pom.xml . + +RUN mvn --batch-mode --errors --quiet dependency:go-offline + +# Copy the relevant directory +COPY src ./src + +# If check passes, build the jar +RUN mvn --batch-mode --errors --quiet clean package -DskipTests + +# Extract the jar file using an efficient layout +RUN java -Djarmode=tools -jar target/project.jar extract --layers --destination extracted + +# Stage 2: Run the application +FROM eclipse-temurin:25.0.2_10-jre-alpine-3.23 + +LABEL org.opencontainers.image.source=https://github.com/notalib/workshop-containerisation +LABEL org.opencontainers.image.description="Spring Boot example application" + +# Upgrade packages +RUN apk upgrade --available --no-cache + +# Copy the extracted jar contents from the builder container into the working directory in the runtime container +# Every copy step creates a new docker layer +# This allows docker to only pull the changes it really needs +COPY --from=builder /app/extracted/dependencies/ ./ +COPY --from=builder /app/extracted/spring-boot-loader/ ./ +COPY --from=builder /app/extracted/snapshot-dependencies/ ./ +COPY --from=builder /app/extracted/application/ ./ -EXPOSE 8080 -VOLUME /tmp -ARG DEPENDENCY=/workdir/server/target/dependency -COPY --from=prepare-production ${DEPENDENCY}/BOOT-INF/lib /app/lib -COPY --from=prepare-production ${DEPENDENCY}/META-INF /app/META-INF -COPY --from=prepare-production ${DEPENDENCY}/BOOT-INF/classes /app COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh + +# Add a non-root user +RUN addgroup --system spring && adduser --system --ingroup spring spring +USER spring:spring + ENTRYPOINT ["/entrypoint.sh"] diff --git a/edu-spring-postgres/backend/entrypoint.sh b/edu-spring-postgres/backend/entrypoint.sh index fa1b61b..b52b01d 100644 --- a/edu-spring-postgres/backend/entrypoint.sh +++ b/edu-spring-postgres/backend/entrypoint.sh @@ -5,4 +5,12 @@ if [ -f /run/secrets/db-password ]; then export POSTGRES_PASSWORD="$(cat /run/secrets/db-password)" fi -exec java -cp "app:app/lib/*" com.company.project.Application +# Run Spring Boot app +# Start the application jar - this is not the uber jar used by the builder +# This jar only contains application code and references to the extracted jar files +# This layout is efficient to start up and AOT cache (and CDS) friendly +# -XX:+UseContainerSupport is on by default in Java 11+ but explicit is better than implicit. +# -XX:MaxRAMPercentage=75.0 tells the JVM to use up to 75% of the container’s available memory for the heap. Leaving the remaining 25% for native memory, Metaspace, and thread stacks is a reasonable default for most applications. +# -XX:+ExitOnOutOfMemoryError makes the JVM exit (and the container restart) rather than limping along in a degraded state when it runs out of memory + +exec java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError -jar project.jar diff --git a/edu-spring-postgres/backend/pom.xml b/edu-spring-postgres/backend/pom.xml index 8c17502..4454646 100755 --- a/edu-spring-postgres/backend/pom.xml +++ b/edu-spring-postgres/backend/pom.xml @@ -2,26 +2,24 @@ 4.0.0 - - com.company - project - 0.0.1-SNAPSHOT - jar - - New App - My new SpringBoot app - org.springframework.boot spring-boot-starter-parent - 3.4.13 + 4.0.6 + com.company + project + 0.0.1-SNAPSHOT + New App + My new SpringBoot app + jar UTF-8 UTF-8 - 21 + 25 + 2.0.5 @@ -44,6 +42,7 @@ org.postgresql postgresql + 42.7.11 runtime @@ -60,43 +59,34 @@ org.springframework.boot spring-boot-starter-test test - - - org.junit.vintage - junit-vintage-engine - - - - org.testcontainers - testcontainers-junit-jupiter - 2.0.5 + org.springframework.boot + spring-boot-resttestclient test org.testcontainers - testcontainers - 2.0.5 + testcontainers-junit-jupiter + ${testcontainers.version} test org.testcontainers testcontainers-postgresql - 2.0.1 + ${testcontainers.version} test - org.jsoup jsoup - 1.18.3 + 1.22.2 test - + ${project.artifactId} org.springframework.boot diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/Application.java b/edu-spring-postgres/backend/src/main/java/com/company/project/Application.java index 24a9edd..7a5a05d 100755 --- a/edu-spring-postgres/backend/src/main/java/com/company/project/Application.java +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/Application.java @@ -6,7 +6,7 @@ @SpringBootApplication public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } } diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/controller/GreetingController.java b/edu-spring-postgres/backend/src/main/java/com/company/project/controller/GreetingController.java new file mode 100755 index 0000000..224442a --- /dev/null +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/controller/GreetingController.java @@ -0,0 +1,77 @@ +package com.company.project.controller; + +import com.company.project.entity.Greeting; +import com.company.project.service.IGreetingService; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +@Controller +public class GreetingController { + + private static final Logger log = LoggerFactory.getLogger(GreetingController.class); + + private final IGreetingService iGreetingService; + + @Autowired + public GreetingController(IGreetingService iGreetingService) { + this.iGreetingService = iGreetingService; + } + + @GetMapping("/") + public String showHome(Model model) { + String greetingDocker = "Docker"; + log.info("GET / — loading default greeting with name='{}'", greetingDocker); + Greeting dockerGreeting = iGreetingService.showHome(greetingDocker).orElseThrow(() -> { + log.warn("Greeting with name='{}' not found", greetingDocker); + return new ResponseStatusException(HttpStatus.NOT_FOUND, "Greeting '" + greetingDocker + "' not found"); + }); + log.info("Loaded greeting: '{}'", dockerGreeting.getName()); + model.addAttribute("name", dockerGreeting.getName()); + model.addAttribute("body", "Connected to database!"); + return "greeting-single"; + } + + @GetMapping("/greetings") + public String listGreetings(Model model) { + log.info("GET /greetings — listing all greetings"); + Iterable greetings = iGreetingService.listGreetings(); + model.addAttribute("greetings", greetings); + return "greetings"; + } + + @GetMapping("/greetings/{id}") + public String sayHello(@PathVariable UUID id, Model model) { + log.info("GET /greetings/{} — looking up greeting", id); + Greeting greeting = iGreetingService.sayHello(id).orElseThrow(() -> { + log.warn("Greeting with id='{}' not found", id); + return new ResponseStatusException(HttpStatus.NOT_FOUND, "Greeting '" + id + "' not found"); + }); + log.info("Found greeting id='{}' name='{}'", id, greeting.getName()); + model.addAttribute("name", greeting.getName()); + model.addAttribute("body", "Greeting #" + id); + return "greeting-single"; + } + + @GetMapping("/new") + public String newGreetingForm() { + return "new"; + } + + @PostMapping("/greetings") + public String createGreeting(@RequestParam String name) { + log.info("POST /greetings — creating new greeting with name='{}'", name); + Greeting greeting = iGreetingService.createGreeting(name); + log.info("Saved new greeting id='{}' name='{}'", greeting.getId(), greeting.getName()); + return "redirect:/greetings/" + greeting.getId(); + } +} diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/controllers/HomeController.java b/edu-spring-postgres/backend/src/main/java/com/company/project/controllers/HomeController.java deleted file mode 100755 index 9ae2ba4..0000000 --- a/edu-spring-postgres/backend/src/main/java/com/company/project/controllers/HomeController.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.company.project.controllers; - -import com.company.project.entity.Greeting; -import com.company.project.repository.GreetingRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.server.ResponseStatusException; - -@Controller -public class HomeController { - - private static final Logger log = LoggerFactory.getLogger(HomeController.class); - - @Autowired - private GreetingRepository repository; - - @Value("${spring.datasource.url}") - private String datasourceUrl; - - @GetMapping("/") - public String showHome(String name, Model model) { - log.info("GET / — loading default greeting (id=1) from {}", datasourceUrl); - Greeting dockerGreeting = repository.findById(1).orElseGet(() -> { - log.warn("Default greeting (id=1) not found in DB"); - return new Greeting("DB not found!"); - }); - log.info("Loaded greeting: {}", dockerGreeting.getName()); - model = model.addAttribute("name", dockerGreeting.getName()); - model = model.addAttribute("body", "Connected to database: " + datasourceUrl); - return "greeting-single"; - } - - @GetMapping("/greetings") - public String listGreetings(Model model) { - log.info("GET /greetings — listing all greetings"); - Iterable greetings = repository.findAll(); - long count = 0; - for (Greeting g : greetings) count++; - log.info("Returned {} greetings from DB", count); - model.addAttribute("greetings", greetings); - return "greetings"; - } - - @GetMapping("/greetings/{id}") - public String sayHello(@PathVariable int id, Model model) { - log.info("GET /greetings/{} — looking up greeting", id); - Greeting greeting = repository.findById(id) - .orElseThrow(() -> { - log.warn("Greeting {} not found", id); - return new ResponseStatusException(HttpStatus.NOT_FOUND, "Greeting " + id + " not found"); - }); - log.info("Found greeting #{}: {}", id, greeting.getName()); - model.addAttribute("name", greeting.getName()); - model.addAttribute("body", "Greeting #" + id); - return "greeting-single"; - } - - @GetMapping("/new") - public String newGreetingForm() { - return "new"; - } - - @PostMapping("/greetings") - public String createGreeting(@RequestParam String name) { - log.info("POST /greetings — creating new greeting with name='{}'", name); - Greeting saved = repository.save(new Greeting(name)); - log.info("Saved new greeting id={} name='{}'", saved.getId(), saved.getName()); - return "redirect:/greetings/" + saved.getId(); - } - -} diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/entity/Greeting.java b/edu-spring-postgres/backend/src/main/java/com/company/project/entity/Greeting.java index 9b5519c..0b477c7 100644 --- a/edu-spring-postgres/backend/src/main/java/com/company/project/entity/Greeting.java +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/entity/Greeting.java @@ -5,33 +5,34 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.util.UUID; @Entity @Table(name = "GREETINGS") public class Greeting { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private int id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + private String name; - public Greeting() { - } + public Greeting() {} public Greeting(String name) { this.name = name; } - public Greeting(int id, String name) { + public Greeting(UUID id, String name) { this.id = id; this.name = name; } - public int getId() { + public UUID getId() { return id; } - public void setId(int id) { + public void setId(UUID id) { this.id = id; } @@ -45,8 +46,12 @@ public void setName(String name) { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } Greeting greeting = (Greeting) o; diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/repository/GreetingRepository.java b/edu-spring-postgres/backend/src/main/java/com/company/project/repository/IGreetingRepository.java similarity index 53% rename from edu-spring-postgres/backend/src/main/java/com/company/project/repository/GreetingRepository.java rename to edu-spring-postgres/backend/src/main/java/com/company/project/repository/IGreetingRepository.java index 8927337..4ef91a9 100644 --- a/edu-spring-postgres/backend/src/main/java/com/company/project/repository/GreetingRepository.java +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/repository/IGreetingRepository.java @@ -1,9 +1,12 @@ package com.company.project.repository; import com.company.project.entity.Greeting; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; @Repository -public interface GreetingRepository extends CrudRepository { +public interface IGreetingRepository extends CrudRepository { + Optional findGreetingByName(String name); } diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/service/GreetingService.java b/edu-spring-postgres/backend/src/main/java/com/company/project/service/GreetingService.java new file mode 100644 index 0000000..d49e356 --- /dev/null +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/service/GreetingService.java @@ -0,0 +1,45 @@ +package com.company.project.service; + +import com.company.project.entity.Greeting; +import com.company.project.repository.IGreetingRepository; +import java.util.Optional; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class GreetingService implements IGreetingService { + + private static final Logger log = LoggerFactory.getLogger(GreetingService.class); + + private final IGreetingRepository iGreetingRepository; + + @Autowired + public GreetingService(IGreetingRepository iGreetingRepository) { + this.iGreetingRepository = iGreetingRepository; + } + + public Optional showHome(String name) { + Optional greeting = iGreetingRepository.findGreetingByName(name); + return greeting; + } + + public Iterable listGreetings() { + Iterable greetings = iGreetingRepository.findAll(); + long countedGreetings = iGreetingRepository.count(); + log.info("Returned {} greetings from DB", countedGreetings); + return greetings; + } + + public Optional sayHello(UUID id) { + Optional greeting = iGreetingRepository.findById(id); + return greeting; + } + + public Greeting createGreeting(String name) { + Greeting greeting = iGreetingRepository.save(new Greeting(name)); + return greeting; + } +} diff --git a/edu-spring-postgres/backend/src/main/java/com/company/project/service/IGreetingService.java b/edu-spring-postgres/backend/src/main/java/com/company/project/service/IGreetingService.java new file mode 100644 index 0000000..5db9d88 --- /dev/null +++ b/edu-spring-postgres/backend/src/main/java/com/company/project/service/IGreetingService.java @@ -0,0 +1,16 @@ +package com.company.project.service; + +import com.company.project.entity.Greeting; +import java.util.Optional; +import java.util.UUID; + +public interface IGreetingService { + + Optional showHome(String name); + + Iterable listGreetings(); + + Optional sayHello(UUID id); + + Greeting createGreeting(String name); +} diff --git a/edu-spring-postgres/backend/src/main/resources/application.properties b/edu-spring-postgres/backend/src/main/resources/application.properties index 36389f0..c0ac7a9 100755 --- a/edu-spring-postgres/backend/src/main/resources/application.properties +++ b/edu-spring-postgres/backend/src/main/resources/application.properties @@ -1,8 +1,6 @@ -spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.format_sql=true spring.jpa.hibernate.ddl-auto=none -spring.jpa.hibernate.show-sql=true -spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +spring.datasource.url=jdbc:postgresql://db:5432/${POSTGRES_DB} spring.datasource.username=postgres spring.datasource.password=${POSTGRES_PASSWORD:db-wrz2z} spring.sql.init.mode=always @@ -14,7 +12,6 @@ logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE logging.level.org.springframework.jdbc.datasource.init=INFO - spring.datasource.hikari.connection-timeout=5000 spring.datasource.hikari.initialization-fail-timeout=-1 spring.datasource.hikari.validation-timeout=2000 diff --git a/edu-spring-postgres/backend/src/main/resources/data.sql b/edu-spring-postgres/backend/src/main/resources/data.sql index aeba1be..1fd1261 100644 --- a/edu-spring-postgres/backend/src/main/resources/data.sql +++ b/edu-spring-postgres/backend/src/main/resources/data.sql @@ -1 +1,4 @@ -INSERT INTO GREETINGS(name) VALUES ('Docker'), ('Workshop'), ('The Future') ON CONFLICT (name) DO NOTHING; +INSERT INTO GREETINGS(id, name) +VALUES (gen_random_uuid(), 'Docker'), + (gen_random_uuid(), 'Workshop'), + (gen_random_uuid(), 'The Future') ON CONFLICT (name) DO NOTHING; diff --git a/edu-spring-postgres/backend/src/main/resources/schema.sql b/edu-spring-postgres/backend/src/main/resources/schema.sql index 195738e..4e70fc7 100644 --- a/edu-spring-postgres/backend/src/main/resources/schema.sql +++ b/edu-spring-postgres/backend/src/main/resources/schema.sql @@ -1,4 +1,4 @@ CREATE TABLE IF NOT EXISTS GREETINGS ( - id serial PRIMARY KEY, + id UUID PRIMARY KEY, name varchar(50) NOT NULL UNIQUE ); diff --git a/edu-spring-postgres/backend/src/test/java/com/company/project/HomeControllerIntegrationTest.java b/edu-spring-postgres/backend/src/test/java/com/company/project/HomeControllerIntegrationTest.java index fa8dadb..263791b 100644 --- a/edu-spring-postgres/backend/src/test/java/com/company/project/HomeControllerIntegrationTest.java +++ b/edu-spring-postgres/backend/src/test/java/com/company/project/HomeControllerIntegrationTest.java @@ -1,73 +1,103 @@ package com.company.project; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + import org.jsoup.Jsoup; import org.jsoup.nodes.Document; -import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.context.WebApplicationContext; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.postgresql.PostgreSQLContainer; -import static org.junit.jupiter.api.Assertions.assertTrue; - @Testcontainers @EnabledIfDockerAvailable @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class HomeControllerIntegrationTest { @Container - static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:12-alpine"); + static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:18.3-alpine3.23"); - @Autowired - private TestRestTemplate restTemplate; + RestTestClient restTestClient; @DynamicPropertySource - static void neo4jProperties(DynamicPropertyRegistry registry) { + static void setProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", () -> postgreSQLContainer.getJdbcUrl()); registry.add("spring.datasource.driver-class-name", () -> postgreSQLContainer.getDriverClassName()); registry.add("spring.datasource.username", () -> postgreSQLContainer.getUsername()); registry.add("spring.datasource.password", () -> postgreSQLContainer.getPassword()); } + @BeforeEach + public void setup(WebApplicationContext context) { + restTestClient = RestTestClient.bindToApplicationContext(context).build(); + } + @Test void testHomeEndpoint() { - ResponseEntity response = restTemplate.getForEntity("/", String.class); - assertTrue(response.getStatusCode().is2xxSuccessful()); - Assertions.assertNotNull(response.getBody()); - assertTrue(response.getBody().contains(postgreSQLContainer.getJdbcUrl())); + restTestClient + .get() + .uri("/") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo(""" + + + + Getting Started: Serving Web Content + + + + +

Hello from Spring Boot in Docker!

+

Connected to database!

+

Other pages:

+ + + """); } @Test void testGreetingsEndpoint() { - ResponseEntity response = restTemplate.getForEntity("/greetings", String.class); - assertTrue(response.getStatusCode().is2xxSuccessful()); - Assertions.assertNotNull(response.getBody()); - Document document = Jsoup.parse(response.getBody()); - assertTrue(document.select("h1:contains(Greetings)").size() == 1); + RestTestClient.ResponseSpec responseSpec = + restTestClient.get().uri("/greetings").exchange().expectStatus().isOk(); + + assertNotNull(responseSpec.returnResult().toString()); + Document document = Jsoup.parse(responseSpec.returnResult().toString()); + assertEquals(1, document.select("h1:contains(Greetings)").size()); assertTrue(document.select("table tbody tr").size() >= 3); - assertTrue(document.select("table tbody tr td a:contains(Docker)").size() == 1); - assertTrue(document.select("table tbody tr td a:contains(Workshop)").size() == 1); - assertTrue(document.select("table tbody tr td a:contains(The Future)").size() == 1); + assertEquals(1, document.select("table tbody tr td a:contains(Docker)").size()); + assertEquals( + 1, document.select("table tbody tr td a:contains(Workshop)").size()); + assertEquals( + 1, document.select("table tbody tr td a:contains(The Future)").size()); } @Test void testNewGreetingEndpoint() { - ResponseEntity response = restTemplate.getForEntity("/new", String.class); - assertTrue(response.getStatusCode().is2xxSuccessful()); - Assertions.assertNotNull(response.getBody()); - Document formPageDocument = Jsoup.parse(response.getBody()); + RestTestClient.ResponseSpec responseSpec = + restTestClient.get().uri("/new").exchange().expectStatus().isOk(); + + assertNotNull(responseSpec.returnResult().toString()); + Document formPageDocument = Jsoup.parse(responseSpec.returnResult().toString()); + assertTrue(formPageDocument.select("form#new-greeting").size() == 1); HttpHeaders headers = new HttpHeaders(); @@ -77,25 +107,55 @@ void testNewGreetingEndpoint() { String newGreetingName = "Integration Test Greeting"; form.add("name", newGreetingName); - HttpEntity> request = new HttpEntity<>(form, headers); - ResponseEntity createResponse = restTemplate.postForEntity("/greetings", request, String.class); + RestTestClient.ResponseSpec createResponse = restTestClient + .post() + .uri("/greetings") + .body(form) + .exchange() + .expectStatus() + .is3xxRedirection(); + + if (createResponse.returnResult().getResponseHeaders().getLocation().getPath() != null) { + String createdGreetingPath = createResponse + .returnResult() + .getResponseHeaders() + .getLocation() + .getPath(); + + assertTrue(createdGreetingPath.matches( + "/greetings/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")); + + RestTestClient.ResponseSpec createdGreetingResponse = restTestClient + .get() + .uri(createdGreetingPath) + .exchange() + .expectStatus() + .isOk(); + + assertNotNull(createdGreetingResponse.returnResult().toString()); + + Document createdGreetingResponseDocument = + Jsoup.parse(createdGreetingResponse.returnResult().toString()); + + assertEquals( + 1, + createdGreetingResponseDocument + .select("p:contains(" + newGreetingName + ")") + .size()); + } - assertTrue(createResponse.getStatusCode().is2xxSuccessful() || createResponse.getStatusCode().is3xxRedirection()); - if (createResponse.getHeaders().getLocation() != null) { - String createdGreetingPath = createResponse.getHeaders().getLocation().toString(); - assertTrue(createdGreetingPath.matches("/greetings/\\d+")); + RestTestClient.ResponseSpec greetingsResponse = + restTestClient.get().uri("/greetings").exchange().expectStatus().isOk(); - ResponseEntity createdGreetingResponse = restTemplate.getForEntity(createdGreetingPath, String.class); - assertTrue(createdGreetingResponse.getStatusCode().is2xxSuccessful()); - Assertions.assertNotNull(createdGreetingResponse.getBody()); - assertTrue(createdGreetingResponse.getBody().contains(newGreetingName)); - } + assertNotNull(greetingsResponse.returnResult().toString()); - ResponseEntity greetingsResponse = restTemplate.getForEntity("/greetings", String.class); - assertTrue(greetingsResponse.getStatusCode().is2xxSuccessful()); - Assertions.assertNotNull(greetingsResponse.getBody()); - assertTrue(greetingsResponse.getBody().contains(newGreetingName)); + Document greetingsResponseDocument = + Jsoup.parse(greetingsResponse.returnResult().toString()); + assertEquals( + 1, + greetingsResponseDocument + .select("table tbody tr td a:contains(" + newGreetingName + ")") + .size()); } - } diff --git a/edu-spring-postgres/compose.yaml b/edu-spring-postgres/compose.yaml index 386b0cd..3ea4171 100644 --- a/edu-spring-postgres/compose.yaml +++ b/edu-spring-postgres/compose.yaml @@ -15,7 +15,7 @@ services: - spring-postgres db: - image: postgres:17 + image: postgres:18.3-alpine3.23 restart: always secrets: - db-password