From 8498f3e5e044bea78351c9da97d0d3c7500a230c Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 09:27:37 +0300 Subject: [PATCH 1/6] test: split Testcontainers-backed tests into Failsafe (*IT) suite --- .../WeatherViewerApplicationIT.java | 38 +++++++++++++++++++ .../WeatherViewerApplicationTests.java | 25 ------------ ...trollerTest.java => HomeControllerIT.java} | 2 +- ...llerTest.java => ProfileControllerIT.java} | 2 +- ...ollerTest.java => SearchControllerIT.java} | 2 +- ...stIntegrationTest.java => ForecastIT.java} | 2 +- ...onIntegrationTest.java => LocationIT.java} | 2 +- ...nTest.java => OpenApiDocumentationIT.java} | 2 +- ...ileIntegrationTest.java => ProfileIT.java} | 2 +- ...archIntegrationTest.java => SearchIT.java} | 2 +- ...tyIntegrationTest.java => SecurityIT.java} | 2 +- ...gnInIntegrationTest.java => SignInIT.java} | 2 +- ...ationTest.java => UserRegistrationIT.java} | 2 +- ...rationTest.java => WeatherApiCacheIT.java} | 2 +- ...IntegrationTest.java => WeatherApiIT.java} | 2 +- ...oryTest.java => LocationRepositoryIT.java} | 4 +- ...ositoryTest.java => UserRepositoryIT.java} | 4 +- ...ava => VerificationTokenRepositoryIT.java} | 4 +- 18 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 src/test/java/com/weatherviewer/WeatherViewerApplicationIT.java delete mode 100644 src/test/java/com/weatherviewer/WeatherViewerApplicationTests.java rename src/test/java/com/weatherviewer/controller/{HomeControllerTest.java => HomeControllerIT.java} (99%) rename src/test/java/com/weatherviewer/controller/{ProfileControllerTest.java => ProfileControllerIT.java} (99%) rename src/test/java/com/weatherviewer/controller/{SearchControllerTest.java => SearchControllerIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{ForecastIntegrationTest.java => ForecastIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{LocationIntegrationTest.java => LocationIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{OpenApiDocumentationIntegrationTest.java => OpenApiDocumentationIT.java} (97%) rename src/test/java/com/weatherviewer/integration/{ProfileIntegrationTest.java => ProfileIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{SearchIntegrationTest.java => SearchIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{SecurityIntegrationTest.java => SecurityIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{SignInIntegrationTest.java => SignInIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{UserRegistrationIntegrationTest.java => UserRegistrationIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{WeatherApiCacheIntegrationTest.java => WeatherApiCacheIT.java} (99%) rename src/test/java/com/weatherviewer/integration/{WeatherApiIntegrationTest.java => WeatherApiIT.java} (99%) rename src/test/java/com/weatherviewer/repository/{LocationRepositoryTest.java => LocationRepositoryIT.java} (97%) rename src/test/java/com/weatherviewer/repository/{UserRepositoryTest.java => UserRepositoryIT.java} (94%) rename src/test/java/com/weatherviewer/repository/{VerificationTokenRepositoryTest.java => VerificationTokenRepositoryIT.java} (96%) diff --git a/src/test/java/com/weatherviewer/WeatherViewerApplicationIT.java b/src/test/java/com/weatherviewer/WeatherViewerApplicationIT.java new file mode 100644 index 0000000..f521e5b --- /dev/null +++ b/src/test/java/com/weatherviewer/WeatherViewerApplicationIT.java @@ -0,0 +1,38 @@ +package com.weatherviewer; + +import com.weatherviewer.testcontainers.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; + +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class WeatherViewerApplicationIT { + + @Autowired + private PostgreSQLContainer postgresContainer; + + @Autowired + private GenericContainer redisContainer; + + @Test + void contextLoads() { + } + + @Test + void mainMethodStartsApplication() { + WeatherViewerApplication.main(new String[] { + "--spring.datasource.url=" + postgresContainer.getJdbcUrl(), + "--spring.datasource.username=" + postgresContainer.getUsername(), + "--spring.datasource.password=" + postgresContainer.getPassword(), + "--spring.datasource.driver-class-name=org.postgresql.Driver", + "--spring.data.redis.host=" + redisContainer.getHost(), + "--spring.data.redis.port=" + redisContainer.getMappedPort(6379), + "--server.port=0", + }); + } + +} diff --git a/src/test/java/com/weatherviewer/WeatherViewerApplicationTests.java b/src/test/java/com/weatherviewer/WeatherViewerApplicationTests.java deleted file mode 100644 index f94b381..0000000 --- a/src/test/java/com/weatherviewer/WeatherViewerApplicationTests.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weatherviewer; - -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.boot.SpringApplication; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mockStatic; - -class WeatherViewerApplicationTests { - - @Test - void mainStartsApplication() { - try (MockedStatic mocked = mockStatic(SpringApplication.class)) { - - WeatherViewerApplication.main(new String[0]); - - mocked.verify(() -> - SpringApplication.run( - eq(WeatherViewerApplication.class), - eq(new String[0]) - )); - } - } -} diff --git a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java b/src/test/java/com/weatherviewer/controller/HomeControllerIT.java similarity index 99% rename from src/test/java/com/weatherviewer/controller/HomeControllerTest.java rename to src/test/java/com/weatherviewer/controller/HomeControllerIT.java index 13e8902..8c3909b 100644 --- a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/HomeControllerIT.java @@ -37,7 +37,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class HomeControllerTest { +class HomeControllerIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java b/src/test/java/com/weatherviewer/controller/ProfileControllerIT.java similarity index 99% rename from src/test/java/com/weatherviewer/controller/ProfileControllerTest.java rename to src/test/java/com/weatherviewer/controller/ProfileControllerIT.java index 581efe8..8168279 100644 --- a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ProfileControllerIT.java @@ -33,7 +33,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class ProfileControllerTest { +class ProfileControllerIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java b/src/test/java/com/weatherviewer/controller/SearchControllerIT.java similarity index 99% rename from src/test/java/com/weatherviewer/controller/SearchControllerTest.java rename to src/test/java/com/weatherviewer/controller/SearchControllerIT.java index 6da305e..e1f044f 100644 --- a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/SearchControllerIT.java @@ -35,7 +35,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class SearchControllerTest { +class SearchControllerIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ForecastIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/ForecastIT.java index fd23fc5..26cef06 100644 --- a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ForecastIT.java @@ -28,7 +28,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class ForecastIntegrationTest { +class ForecastIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/LocationIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/LocationIT.java index e366975..4259fd3 100644 --- a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/LocationIT.java @@ -32,7 +32,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class LocationIntegrationTest { +class LocationIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIT.java similarity index 97% rename from src/test/java/com/weatherviewer/integration/OpenApiDocumentationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/OpenApiDocumentationIT.java index 282ff80..f0ede94 100644 --- a/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIT.java @@ -14,7 +14,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class OpenApiDocumentationIntegrationTest { +class OpenApiDocumentationIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ProfileIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/ProfileIT.java index 4329872..500fe24 100644 --- a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ProfileIT.java @@ -29,7 +29,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class ProfileIntegrationTest { +class ProfileIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java b/src/test/java/com/weatherviewer/integration/SearchIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SearchIT.java index af3c21c..9d67770 100644 --- a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SearchIT.java @@ -36,7 +36,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class SearchIntegrationTest { +class SearchIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/SecurityIntegrationTest.java b/src/test/java/com/weatherviewer/integration/SecurityIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/SecurityIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SecurityIT.java index a97c4ad..8e28102 100644 --- a/src/test/java/com/weatherviewer/integration/SecurityIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SecurityIT.java @@ -18,7 +18,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class SecurityIntegrationTest { +class SecurityIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/SignInIntegrationTest.java b/src/test/java/com/weatherviewer/integration/SignInIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/SignInIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SignInIT.java index 947d06f..df99ea4 100644 --- a/src/test/java/com/weatherviewer/integration/SignInIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SignInIT.java @@ -25,7 +25,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class SignInIntegrationTest { +class SignInIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/UserRegistrationIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/UserRegistrationIT.java index 1e25b67..c5c34fb 100644 --- a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/UserRegistrationIT.java @@ -21,7 +21,7 @@ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) -class UserRegistrationIntegrationTest { +class UserRegistrationIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/integration/WeatherApiCacheIntegrationTest.java b/src/test/java/com/weatherviewer/integration/WeatherApiCacheIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/WeatherApiCacheIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/WeatherApiCacheIT.java index 015e2f4..142d5e3 100644 --- a/src/test/java/com/weatherviewer/integration/WeatherApiCacheIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/WeatherApiCacheIT.java @@ -29,7 +29,7 @@ */ @SpringBootTest @Import(TestcontainersConfiguration.class) -class WeatherApiCacheIntegrationTest { +class WeatherApiCacheIT { private final ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java b/src/test/java/com/weatherviewer/integration/WeatherApiIT.java similarity index 99% rename from src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/WeatherApiIT.java index 6ad4efb..7efa9c9 100644 --- a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/WeatherApiIT.java @@ -30,7 +30,7 @@ @SpringBootTest @Import(TestcontainersConfiguration.class) -class WeatherApiIntegrationTest { +class WeatherApiIT { @Autowired private RestClient.Builder restClientBuilder; diff --git a/src/test/java/com/weatherviewer/repository/LocationRepositoryTest.java b/src/test/java/com/weatherviewer/repository/LocationRepositoryIT.java similarity index 97% rename from src/test/java/com/weatherviewer/repository/LocationRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/LocationRepositoryIT.java index f685679..1e534f3 100644 --- a/src/test/java/com/weatherviewer/repository/LocationRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/LocationRepositoryIT.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; @@ -18,7 +19,8 @@ @DataJpaTest @Import(TestcontainersConfiguration.class) -class LocationRepositoryTest { +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class LocationRepositoryIT { @Autowired TestEntityManager entityManager; diff --git a/src/test/java/com/weatherviewer/repository/UserRepositoryTest.java b/src/test/java/com/weatherviewer/repository/UserRepositoryIT.java similarity index 94% rename from src/test/java/com/weatherviewer/repository/UserRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/UserRepositoryIT.java index ec18104..d1b8585 100644 --- a/src/test/java/com/weatherviewer/repository/UserRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/UserRepositoryIT.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; @@ -19,7 +20,8 @@ @DataJpaTest @Import(TestcontainersConfiguration.class) -class UserRepositoryTest { +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UserRepositoryIT { @Autowired TestEntityManager entityManager; diff --git a/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryIT.java similarity index 96% rename from src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryIT.java index 7e16c1e..81c71d8 100644 --- a/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryIT.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; @@ -20,7 +21,8 @@ @DataJpaTest @Import(TestcontainersConfiguration.class) -class VerificationTokenRepositoryTest { +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class VerificationTokenRepositoryIT { @Autowired TestEntityManager entityManager; From e70e2c37395f5d01a1376d5ef2aac74c54003bf7 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 09:27:53 +0300 Subject: [PATCH 2/6] build: run unit and integration tests via separate JaCoCo agents, merge coverage --- pom.xml | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e4ae47a..f2acdb7 100644 --- a/pom.xml +++ b/pom.xml @@ -235,12 +235,42 @@ prepare-agent + + prepare-agent-integration + + prepare-agent-integration + + + failsafeArgLine + + + + merge-results + post-integration-test + + merge + + + + + ${project.build.directory} + + *.exec + + + + ${project.build.directory}/jacoco-merged.exec + + report - test + post-integration-test report + + ${project.build.directory}/jacoco-merged.exec + check @@ -248,6 +278,7 @@ check + ${project.build.directory}/jacoco-merged.exec BUNDLE @@ -264,6 +295,22 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${failsafeArgLine} + + + + + integration-test + verify + + + + org.apache.maven.plugins maven-javadoc-plugin From c5cc25d49480426a172a72128c7d5fa9c3c91987 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 09:28:10 +0300 Subject: [PATCH 3/6] ci: run mvn verify instead of test, publish Failsafe reports too --- .github/workflows/ci.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e926df..aaf8670 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,19 +32,16 @@ jobs: run: chmod +x ./mvnw - name: Run tests - # Tests run against the in-memory H2 DB and spring.cache.type=simple, - # so no Postgres/Redis service containers are needed here. - # WEATHER_API_KEY just needs to be non-empty; tests mock the HTTP client. env: WEATHER_API_KEY: dummy-ci-key - run: ./mvnw -B test + run: ./mvnw -B verify - name: Publish test report if: always() uses: dorny/test-reporter@v3 with: name: Maven Tests - path: target/surefire-reports/*.xml + path: "target/surefire-reports/*.xml,target/failsafe-reports/*.xml" reporter: java-junit - name: Upload JaCoCo coverage report From 2a82a149980d2b677011274c9d044d5022e3adcf Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 09:28:21 +0300 Subject: [PATCH 4/6] docs: document mvn test vs mvn verify in README --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 04b3586..3e8279f 100644 --- a/README.md +++ b/README.md @@ -261,16 +261,19 @@ Every log line is tagged with a request correlation ID, and HTTP request latency ## Running Tests ```bash -./mvnw test +./mvnw test # fast, Docker-free: unit tests + @WebMvcTest slices +./mvnw verify # everything above, plus the *IT integration suite ``` -Every test that boots a Spring context (`@SpringBootTest`, `@DataJpaTest`) runs against real Postgres and Redis via [Testcontainers](https://testcontainers.com/) — `TestcontainersConfiguration` wires both in via `@ServiceConnection`, so no manual datasource/Redis properties are needed. This needs a running Docker daemon; without one, those tests fail to start. Pure unit tests (model/DTO/enum tests, Mockito-based service tests) don't start a Spring context at all, so they're unaffected either way. The suite includes unit tests, MVC/REST controller tests (`@WebMvcTest`, which slice the web layer and don't touch a real database), repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests. +Unit tests (model/DTO/enum tests, Mockito-based service tests) and `@WebMvcTest` controller slices don't start a real datasource at all, so `./mvnw test` alone needs nothing but a JDK — no Docker required. Classes named `*IT` (e.g. `UserRepositoryIT`, `SignInIT`) are the ones that boot a full Spring context against real Postgres and Redis via [Testcontainers](https://testcontainers.com/) — `TestcontainersConfiguration` wires both in via `@ServiceConnection`. Maven's Failsafe plugin only runs those during `./mvnw verify`, not `./mvnw test`, so a running Docker daemon is only required for `verify`. + +The suite covers unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo instruments both Surefire (`test`) and Failsafe (`*IT`) runs separately, then merges the two into one combined report — that merge, and the report itself, only happen as part of `./mvnw verify`, at `target/site/jacoco/index.html`. The 90% line-coverage gate (`jacoco:check`) reads that same merged data and only runs during `verify` as well. ## CI/CD Every push to `main` and every pull request into `main`/`dev` runs through GitHub Actions: -1. **Build & Test** — compiles the project and runs the full test suite (Spring-context tests against real Postgres/Redis via Testcontainers), publishing a JUnit test report and a JaCoCo coverage report as workflow artifacts. +1. **Build & Test** — runs `./mvnw verify`: unit/slice tests via Surefire plus the `*IT` integration suite via Failsafe (real Postgres/Redis via Testcontainers), publishing a JUnit test report and the merged JaCoCo coverage report as workflow artifacts. 2. **Update coverage badge** — on pushes to `main` or `dev`, regenerates that branch's `.github/badges/jacoco.svg` badge from the JaCoCo report and commits it back. 3. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`. From 36f7eccbe2fcf805af1ba700fc01f05f6321198a Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 10:46:16 +0300 Subject: [PATCH 5/6] feat(monitoring): add Grafana dashboards, Prometheus config, and CacheConfig - Add CacheConfig to enable Redis cache statistics for Micrometer - Add SecurityConfig rule to permit Actuator endpoint access - Add Grafana provisioning files (dashboards.yml, datasource.yml) - Add weather-viewer overview dashboard JSON - Add Prometheus scrape configuration - Update docker-compose.yml and README.md --- README.md | 24 +++++ docker-compose.yml | 48 ++++++++- .../dashboards/weather-viewer-overview.json | 102 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 12 +++ .../provisioning/datasources/datasource.yml | 10 ++ monitoring/prometheus/prometheus.yml | 9 ++ .../com/weatherviewer/config/CacheConfig.java | 18 ++++ .../weatherviewer/config/SecurityConfig.java | 2 +- 8 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 monitoring/grafana/dashboards/weather-viewer-overview.json create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/datasources/datasource.yml create mode 100644 monitoring/prometheus/prometheus.yml create mode 100644 src/main/java/com/weatherviewer/config/CacheConfig.java diff --git a/README.md b/README.md index 3e8279f..a44b6b3 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,30 @@ Actuator runs on a separate management port so it can be kept off the public net Every log line is tagged with a request correlation ID, and HTTP request latency is exported as a histogram for easy percentile/SLO tracking. +`docker-compose.yml` also runs a Prometheus + Grafana stack alongside the app, scraping `/actuator/prometheus` every 15s: + +```bash +docker compose up -d +``` + +| Service | URL | Notes | +|:-----------|:------------------------|:---------------------------------------------------------------| +| Prometheus | http://localhost:9090 | Scrapes `weather_viewer:8081/actuator/prometheus` | +| Grafana | http://localhost:3000 | Login `admin` / `admin` (dev-only default, see below) | + +Grafana auto-provisions the Prometheus datasource and a starter **Weather Viewer — Overview** dashboard on first startup — nothing to click through manually. It covers HTTP request rate/p95 latency, JVM heap usage, the Redis cache hit ratio, and the `weatherApi` circuit breaker state and retry calls (the same Resilience4j instance the [architecture diagrams](#architecture) above describe). Config lives under `monitoring/`: + +``` +monitoring/ +├── prometheus/prometheus.yml # scrape target + interval +└── grafana/ + ├── provisioning/datasources/datasource.yml # auto-adds Prometheus + ├── provisioning/dashboards/dashboards.yml # tells Grafana where to look + └── dashboards/weather-viewer-overview.json # the starter dashboard itself +``` + +Grafana's admin login comes from `GRAFANA_ADMIN_USER`/`GRAFANA_ADMIN_PASSWORD` in `.env` (same pattern as `POSTGRES_PASSWORD`), falling back to `admin`/`admin` if unset — fine for a quick local run, but set them in `.env` before running this anywhere reachable off your own machine. + ## Security - Passwords are hashed with BCrypt; sign-in is protected by per-account lockout after repeated failed attempts diff --git a/docker-compose.yml b/docker-compose.yml index 0d395dc..7d1839a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: - weather_viewer: + weather-viewer: build: context: . dockerfile: Dockerfile image: podllev/weather-viewer:latest - container_name: weather_viewer + container_name: weather-viewer restart: always ports: - "8080:8080" @@ -28,7 +28,7 @@ services: postgres: image: postgres:17 - container_name: weather_viewer_db + container_name: weather-viewer-db restart: always env_file: - .env @@ -47,7 +47,7 @@ services: redis: image: redis:7 - container_name: weather_viewer_redis + container_name: weather-viewer-redis restart: always ports: - "6379:6379" @@ -59,6 +59,46 @@ services: timeout: 5s retries: 5 + prometheus: + image: prom/prometheus:v3.8.1 + container_name: weather-viewer-prometheus + restart: always + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + ports: + - "9090:9090" + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/ready" ] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + depends_on: + weather-viewer: + condition: service_healthy + + grafana: + image: grafana/grafana:13.1.1 + container_name: weather-viewer-grafana + restart: always + env_file: + - .env + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + - GF_AUTH_ANONYMOUS_ENABLED=false + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + ports: + - "3000:3000" + depends_on: + - prometheus + volumes: postgres_data: redis_data: + prometheus_data: + grafana_data: \ No newline at end of file diff --git a/monitoring/grafana/dashboards/weather-viewer-overview.json b/monitoring/grafana/dashboards/weather-viewer-overview.json new file mode 100644 index 0000000..c5617b3 --- /dev/null +++ b/monitoring/grafana/dashboards/weather-viewer-overview.json @@ -0,0 +1,102 @@ +{ + "title": "Weather Viewer — Overview", + "uid": "weather-viewer-overview", + "schemaVersion": 39, + "version": 1, + "editable": true, + "timezone": "browser", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "tags": ["weather-viewer"], + "panels": [ + { + "id": 1, + "title": "HTTP request rate", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count[1m])) by (uri, status)", + "legendFormat": "{{uri}} [{{status}}]" + } + ], + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] } + }, + { + "id": 2, + "title": "HTTP p95 latency", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] } + }, + { + "id": 3, + "title": "JVM heap used", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) by (id)", + "legendFormat": "{{id}}" + } + ], + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] } + }, + { + "id": 4, + "title": "Cache hit ratio", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "description": "Requires spring.cache.type=redis — the simple in-memory cache profile doesn't emit cache.gets metrics.", + "targets": [ + { + "expr": "sum(rate(cache_gets_total{result=\"hit\"}[5m])) by (cache) / sum(rate(cache_gets_total[5m])) by (cache)", + "legendFormat": "{{cache}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, + "overrides": [] + } + }, + { + "id": 5, + "title": "weatherApi circuit breaker state", + "type": "state-timeline", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "description": "0 = closed, 1 = open, 2 = half-open.", + "targets": [ + { + "expr": "resilience4j_circuitbreaker_state{name=\"weatherApi\"}", + "legendFormat": "{{state}}" + } + ] + }, + { + "id": 6, + "title": "weatherApi retry calls", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "description": "kind: successful_without_retry / successful_with_retry / failed_with_retry / failed_without_retry", + "targets": [ + { + "expr": "sum(rate(resilience4j_retry_calls_total{name=\"weatherApi\"}[5m])) by (kind)", + "legendFormat": "{{kind}}" + } + ], + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] } + } + ] +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..3efe969 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: WeatherViewer + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..00f9915 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..bc65d76 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: weather-viewer + metrics_path: /actuator/prometheus + static_configs: + - targets: ["weather-viewer:8081"] \ No newline at end of file diff --git a/src/main/java/com/weatherviewer/config/CacheConfig.java b/src/main/java/com/weatherviewer/config/CacheConfig.java new file mode 100644 index 0000000..87fcff3 --- /dev/null +++ b/src/main/java/com/weatherviewer/config/CacheConfig.java @@ -0,0 +1,18 @@ +package com.weatherviewer.config; + +import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheManager; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() { + return RedisCacheManager.RedisCacheManagerBuilder::enableStatistics; + } + +} diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index f526b76..a6c4751 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -80,7 +80,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/verify-email", "/resend-verification", "/forgot-password", "/reset-password", "/css/**", "/images/**", "/js/**", - "/actuator/health", "/actuator/health/**" + "/actuator", "/actuator/**" ).permitAll() .requestMatchers( "/swagger-ui.html", "/swagger-ui/**", From 71b63222f86c98d9740bb73e48e167547f253590 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 5 Aug 2026 10:56:28 +0300 Subject: [PATCH 6/6] fix(ci): update test-reporter path pattern and add checks permission - Update surefire and failsafe test report paths to TEST-*.xml to ignore failsafe-summary.xml - Add checks: write permission to test job for publishing check runs --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaf8670..35d614d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: test: name: Build & Test runs-on: ubuntu-latest + permissions: + contents: read + checks: write steps: - name: Checkout code uses: actions/checkout@v7 @@ -41,7 +44,7 @@ jobs: uses: dorny/test-reporter@v3 with: name: Maven Tests - path: "target/surefire-reports/*.xml,target/failsafe-reports/*.xml" + path: "target/surefire-reports/TEST-*.xml,target/failsafe-reports/TEST-*.xml" reporter: java-junit - name: Upload JaCoCo coverage report