Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4af2f43
feat(map): implement OpenWeatherMap tile API client and tile layer enum
podlLev Jul 30, 2026
294754b
feat(map): expose REST endpoints for proxying weather map tile layers
podlLev Jul 30, 2026
d1956d7
feat(map): add MapController and i18n properties for map page
podlLev Jul 30, 2026
e3fac58
feat(ui): add Leaflet map view, tile overlay switcher, and navigation…
podlLev Jul 30, 2026
4db9506
fix(map): inject CSRF token into map JSON data model
podlLev Jul 31, 2026
58f63c5
fix(map): support CSRF submission in interactive map popup form
podlLev Jul 31, 2026
df87f68
feat(i18n): add map overlay and popup translation keys
podlLev Jul 31, 2026
2acf5b1
Merge pull request #42 from podlLev/feature/weather-map
podlLev Jul 31, 2026
bd63a6d
feat(build): add websocket and reactor dependencies
podlLev Aug 1, 2026
09136c5
feat(config): enable scheduling for live weather push
podlLev Aug 1, 2026
c900d01
feat(ws): configure stomp endpoint with authenticated handshake
podlLev Aug 1, 2026
82fe6a1
feat(ws): add request/push dtos for dashboard and forecast updates
podlLev Aug 1, 2026
ae69960
feat(ws): add registry for active live weather subscriptions
podlLev Aug 1, 2026
dc9db7d
feat(ws): handle subscribe frames and disconnect cleanup
podlLev Aug 1, 2026
52eeeea
feat(ws): add scheduled broadcast of live weather updates
podlLev Aug 1, 2026
83d57f1
feat(ui): add shared stomp client for live weather feeds
podlLev Aug 1, 2026
6ffe235
feat(dashboard): wire live weather updates into dashboard cards
podlLev Aug 1, 2026
7beca3d
feat(forecast): wire live weather updates into forecast cards
podlLev Aug 1, 2026
d757e44
test(forecast): add controller and websocket tests
podlLev Aug 2, 2026
5e59384
test(dashboard): add websocket update tests
podlLev Aug 2, 2026
036b1a3
test(websocket): add tests for socket controller and subscriptions
podlLev Aug 2, 2026
b784dfd
test(weather): add scheduler and websocket configuration tests
podlLev Aug 2, 2026
8de62b8
Merge pull request #43 from podlLev/feature/websocket
podlLev Aug 2, 2026
96f9917
ci: add coverage-badge job to generate and commit JaCoCo badge
podlLev Aug 3, 2026
faf53b0
docs: add CI/coverage badges and architecture diagrams to README
podlLev Aug 3, 2026
e0b33b6
docs: add system architecture overview diagram to README
podlLev Aug 3, 2026
a13ac36
chore: update coverage badge [skip ci]
podlLev Aug 3, 2026
fe6b76b
Merge pull request #44 from podlLev/ci/coverage-badge-and-arch-diagram
podlLev Aug 3, 2026
fc7fecc
test: add Testcontainers support for integration tests
podlLev Aug 4, 2026
4471743
test: add controller integration tests
podlLev Aug 4, 2026
2d3a2a4
test: add repository integration tests
podlLev Aug 4, 2026
959b3f7
test: add integration tests with Testcontainers
podlLev Aug 4, 2026
7c46fee
Merge pull request #45 from podlLev/feature/test-containers
podlLev Aug 4, 2026
8498f3e
test: split Testcontainers-backed tests into Failsafe (*IT) suite
podlLev Aug 5, 2026
e70e2c3
build: run unit and integration tests via separate JaCoCo agents, mer…
podlLev Aug 5, 2026
c5cc25d
ci: run mvn verify instead of test, publish Failsafe reports too
podlLev Aug 5, 2026
2a82a14
docs: document mvn test vs mvn verify in README
podlLev Aug 5, 2026
36f7ecc
feat(monitoring): add Grafana dashboards, Prometheus config, and Cach…
podlLev Aug 5, 2026
71b6322
fix(ci): update test-reporter path pattern and add checks permission
podlLev Aug 5, 2026
bc83a2f
Merge pull request #46 from podlLev/feature/failsafe
podlLev Aug 5, 2026
9b0ad9b
fix(cache): register initial cache names for Redis cache metrics
podlLev Aug 6, 2026
542ad36
chore(release): bump version to 1.3.0
podlLev Aug 6, 2026
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
1 change: 1 addition & 0 deletions .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 45 additions & 6 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 All @@ -66,7 +66,46 @@ jobs:
path: target/*.jar

# ---------------------------------------------------------------------
# 2. Build + push a versioned image to Docker Hub
# 2. Regenerate the coverage badge from the JaCoCo report and commit it
# - runs ONLY on: pushes to main or dev
# - the ref check alone excludes pull_request events: their ref is
# refs/pull/<number>/merge, never refs/heads/main or .../dev
# - actions/checkout defaults to the triggering ref, so the commit
# lands back on whichever branch (main or dev) was just pushed
# - reads target/site/jacoco/jacoco.csv from the `test` job above
# ---------------------------------------------------------------------
coverage-badge:
name: Update Coverage Badge
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Download JaCoCo report
uses: actions/download-artifact@v7
with:
name: jacoco-report
path: target/site/jacoco

- name: Generate coverage badge
uses: cicirello/jacoco-badge-generator@v2
with:
jacoco-csv-file: target/site/jacoco/jacoco.csv
badges-directory: .github/badges
generate-branches-badge: false
generate-summary: false

- name: Commit badge
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update coverage badge [skip ci]"
file_pattern: .github/badges/*.svg

# ---------------------------------------------------------------------
# 3. Build + push a versioned image to Docker Hub
# - runs ONLY on: pushes to main
# - every push to main -> :<pom-version> :latest
# - pushes to dev, and all PRs -> this job does not run at all
Expand Down
94 changes: 89 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
<img src="https://img.shields.io/badge/PostgreSQL-17-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL 17">
<img src="https://img.shields.io/badge/Redis-7-DC382D?logo=redis&logoColor=white" alt="Redis 7">
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white" alt="Docker ready">
<img src="https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?logo=githubactions&logoColor=white" alt="CI GitHub Actions">
<a href="https://github.com/podlLev/WeatherViewer/actions/workflows/ci.yml"><img src="https://github.com/podlLev/WeatherViewer/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status"></a>
<img src="https://raw.githubusercontent.com/podlLev/WeatherViewer/main/.github/badges/jacoco.svg" alt="Coverage">
</p>

<p align="center">
<a href="#overview">Overview</a> ·
<a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#getting-started">Getting Started</a> ·
<a href="#api-documentation">API Docs</a> ·
<a href="#observability">Observability</a> ·
Expand Down Expand Up @@ -74,6 +76,60 @@ WeatherViewer is a personal weather dashboard for tracking the places you care a
| CI/CD | GitHub Actions (build/test, coverage, Docker Hub image push) |
| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) |

## Architecture

**System overview** — the app sits between the browser and four external dependencies. Every HTTP request passes through the rate limiter and the security filter chain before reaching a controller; live dashboard/forecast updates instead flow over a persistent WebSocket connection, pushed on a schedule rather than requested:

```mermaid
flowchart TB
Client[Browser client]
RL[Rate limiter]
Sec[Security filter chain]
Web[Controllers + REST]
WS[WebSocket / STOMP]
Svc[Services]
DB[(PostgreSQL)]
Cache[(Redis)]
Weather[(OpenWeatherMap API)]
Mail[(SMTP)]

Client --> RL --> Sec --> Web
Client -. live updates .-> WS
Web --> Svc
WS --> Svc
Svc --> DB
Svc --> Cache
Svc --> Weather
Svc --> Mail
```

Postgres holds users, locations, and tokens (schema managed by Liquibase). Redis backs both the rate limiter's fixed-window counters and the weather/forecast/geocoding cache. The two flows below zoom into the parts of this picture that need to tolerate a flaky dependency: weather reads and outbound mail.

Two request paths matter most for reliability: reads that hit the OpenWeatherMap API, and emails triggered by account actions. Both are built so a slow or failing dependency degrades gracefully instead of taking the app down with it.

**Weather read path** — a cache-aside read guarded by retry and a circuit breaker:

```mermaid
flowchart LR
A[Controller] --> B["Cache<br/>@Cacheable"]
B --> C["Client<br/>retry + breaker"]
C --> D[("Weather API")]
C -. fallback .-> E["Fallback<br/>service unavailable"]
```

A cache hit never reaches `WeatherApiClient`. On a miss, every outbound call is wrapped with Resilience4j: transient failures are retried with backoff, and once OpenWeatherMap is failing consistently the breaker opens and short-circuits straight to the fallback instead of piling up slow requests — so one saved location failing to load doesn't take the rest of the dashboard down with it.

**Async mail path** — a write that only sends mail after its transaction commits:

```mermaid
flowchart LR
F["Service<br/>writes token"] --> G["Event<br/>after commit"]
G --> H["Listener<br/>@Async"]
H --> I[("SMTP")]
```

Verification and password-reset emails are sent from a `@TransactionalEventListener(phase = AFTER_COMMIT)`, so an email can never reference a token whose transaction rolled back. The send itself runs `@Async` on a dedicated pool, so a slow SMTP server can't add latency to the request that triggered it. `MailService` retries transient SMTP failures on its own and never throws — a failure there is logged and goes no further.

## Prerequisites

- Java 17+
Expand Down Expand Up @@ -193,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 @@ -205,17 +285,21 @@ 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
```

Tests run against an in-memory H2 database, so no external services are required. The suite includes 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 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 against H2, publishing a JUnit test report and a JaCoCo coverage report as workflow artifacts.
2. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`.
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`.

See `.github/workflows/ci.yml` for the full pipeline.

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": [] }
}
]
}
Loading
Loading