Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,19 +35,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/TEST-*.xml,target/failsafe-reports/TEST-*.xml"
reporter: java-junit

- name: Upload JaCoCo coverage report
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -261,16 +285,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`.

Expand Down
48 changes: 44 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -28,7 +28,7 @@ services:

postgres:
image: postgres:17
container_name: weather_viewer_db
container_name: weather-viewer-db
restart: always
env_file:
- .env
Expand All @@ -47,7 +47,7 @@ services:

redis:
image: redis:7
container_name: weather_viewer_redis
container_name: weather-viewer-redis
restart: always
ports:
- "6379:6379"
Expand All @@ -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:
102 changes: 102 additions & 0 deletions monitoring/grafana/dashboards/weather-viewer-overview.json
Original file line number Diff line number Diff line change
@@ -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": [] }
}
]
}
12 changes: 12 additions & 0 deletions monitoring/grafana/provisioning/dashboards/dashboards.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions monitoring/grafana/provisioning/datasources/datasource.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
apiVersion: 1

datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
9 changes: 9 additions & 0 deletions monitoring/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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"]
49 changes: 48 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -235,19 +235,50 @@
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>prepare-agent-integration</id>
<goals>
<goal>prepare-agent-integration</goal>
</goals>
<configuration>
<propertyName>failsafeArgLine</propertyName>
</configuration>
</execution>
<execution>
<id>merge-results</id>
<phase>post-integration-test</phase>
<goals>
<goal>merge</goal>
</goals>
<configuration>
<fileSets>
<fileSet>
<directory>${project.build.directory}</directory>
<includes>
<include>*.exec</include>
</includes>
</fileSet>
</fileSets>
<destFile>${project.build.directory}/jacoco-merged.exec</destFile>
</configuration>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<phase>post-integration-test</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/jacoco-merged.exec</dataFile>
</configuration>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/jacoco-merged.exec</dataFile>
<rules>
<rule>
<element>BUNDLE</element>
Expand All @@ -264,6 +295,22 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<!--suppress UnresolvedMavenProperty -->
<argLine>${failsafeArgLine}</argLine>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/weatherviewer/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
Loading
Loading