diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg new file mode 100644 index 0000000..3d59126 --- /dev/null +++ b/.github/badges/jacoco.svg @@ -0,0 +1 @@ +coverage99.4% \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac8ae7b..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 @@ -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 @@ -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//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 -> : :latest # - pushes to dev, and all PRs -> this job does not run at all diff --git a/README.md b/README.md index 17951d2..a44b6b3 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ PostgreSQL 17 Redis 7 Docker ready - CI GitHub Actions + CI status + Coverage

Overview · Features · Tech Stack · + Architecture · Getting Started · API Docs · Observability · @@ -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
@Cacheable"] + B --> C["Client
retry + breaker"] + C --> D[("Weather API")] + C -. fallback .-> E["Fallback
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
writes token"] --> G["Event
after commit"] + G --> H["Listener
@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+ @@ -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 @@ -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. 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/pom.xml b/pom.xml index 2928ac7..7ce0f90 100644 --- a/pom.xml +++ b/pom.xml @@ -10,9 +10,9 @@ com WeatherViewer - 1.2.0 + 1.3.0 WeatherViewer - A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. Supports English and Ukrainian locales. + A Spring Boot application for checking weather forecasts on an interactive map with live WebSocket updates, managing favorite locations, and tracking request metrics with built-in rate limiting and Prometheus/Grafana observability. Supports English and Ukrainian locales. @@ -53,6 +53,14 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + + + io.projectreactor + reactor-core + org.springframework.boot spring-boot-starter-data-jpa @@ -158,8 +166,18 @@ 4.0.0 - com.h2database - h2 + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql test @@ -217,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 @@ -230,6 +278,7 @@ check + ${project.build.directory}/jacoco-merged.exec BUNDLE @@ -246,6 +295,22 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${failsafeArgLine} + + + + + integration-test + verify + + + + org.apache.maven.plugins maven-javadoc-plugin diff --git a/src/main/java/com/weatherviewer/WeatherViewerApplication.java b/src/main/java/com/weatherviewer/WeatherViewerApplication.java index 79f5158..6cfc2fd 100644 --- a/src/main/java/com/weatherviewer/WeatherViewerApplication.java +++ b/src/main/java/com/weatherviewer/WeatherViewerApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @@ -13,14 +14,17 @@ * Bootstraps the application context and enables Spring's caching * abstraction ({@link EnableCaching}), which backs the weather/forecast/ * geocoding response caching in - * {@link com.weatherviewer.service.integration.WeatherApiCache}, and - * Spring's {@code @Async} support ({@link EnableAsync}), which backs - * asynchronous mail dispatch in - * {@link com.weatherviewer.service.impl.MailEventListener}. + * {@link com.weatherviewer.service.integration.WeatherApiCache}; Spring's + * {@code @Async} support ({@link EnableAsync}), which backs asynchronous + * mail dispatch in {@link com.weatherviewer.service.impl.MailEventListener}; + * and {@code @Scheduled} support ({@link EnableScheduling}), which drives + * the periodic live weather broadcast in + * {@link com.weatherviewer.websocket.WeatherLiveUpdateScheduler}. */ @SpringBootApplication @EnableCaching @EnableAsync +@EnableScheduling public class WeatherViewerApplication { /** 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..1cad815 --- /dev/null +++ b/src/main/java/com/weatherviewer/config/CacheConfig.java @@ -0,0 +1,21 @@ +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 java.util.Set; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() { + return builder -> builder + .enableStatistics() + .initialCacheNames(Set.of("weatherCache", "forecastCache", "geoCache")); + } + +} diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index 39530b6..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/**", @@ -121,10 +121,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .contentSecurityPolicy(csp -> csp .policyDirectives( "default-src 'self'; " + - "script-src 'self' https://cdn.jsdelivr.net; " + - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://use.fontawesome.com https://cdnjs.cloudflare.com; " + - "connect-src 'self' https://cdn.jsdelivr.net; " + + "connect-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " + "img-src 'self' data: https:;" ) ) diff --git a/src/main/java/com/weatherviewer/config/WebSocketConfig.java b/src/main/java/com/weatherviewer/config/WebSocketConfig.java new file mode 100644 index 0000000..587eeab --- /dev/null +++ b/src/main/java/com/weatherviewer/config/WebSocketConfig.java @@ -0,0 +1,79 @@ +package com.weatherviewer.config; + +import com.weatherviewer.websocket.WeatherSocketController; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.lang.NonNull; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +/** + * Wires up STOMP-over-WebSocket messaging for live weather updates. + *

+ * The app stays on plain (non-SockJS) WebSocket: every browser this app + * targets supports it natively, and skipping SockJS avoids its XHR-polling + * fallback transports, which are same-origin POSTs that would otherwise + * need a CSRF-exemption carve-out in {@link com.weatherviewer.config.SecurityConfig}. + * The handshake itself is a plain {@code GET} on {@code /ws} and is subject + * to the app's normal {@code anyRequest().authenticated()} rule, so only a + * signed-in session can open the socket in the first place. + *

+ * Destination layout: + *

+ * Both queues are per-user (not broadcast topics): weather is unit-converted + * per viewer and dashboard contents are private to their owner, so a shared + * {@code /topic/**} broadcast isn't the right shape here. + */ +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setHandshakeHandler(new AuthenticationHandshakeHandler()); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue"); + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user"); + } + + /** + * Copies the {@link Authentication} already established for the + * handshake HTTP request (loaded from the session by Spring Security's + * filter chain, same as any other authenticated request) onto the + * WebSocket session as its {@link Principal}. Without this, every STOMP + * session would be anonymous and {@code convertAndSendToUser} would have + * no username to route on. + */ + static class AuthenticationHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser(@NonNull ServerHttpRequest request, + @NonNull WebSocketHandler wsHandler, + @NonNull Map attributes) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return authentication; + } + } + +} diff --git a/src/main/java/com/weatherviewer/controller/ForecastController.java b/src/main/java/com/weatherviewer/controller/ForecastController.java index c8cae59..7330643 100644 --- a/src/main/java/com/weatherviewer/controller/ForecastController.java +++ b/src/main/java/com/weatherviewer/controller/ForecastController.java @@ -1,6 +1,8 @@ package com.weatherviewer.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.enums.WeatherCondition; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; @@ -9,13 +11,17 @@ import com.weatherviewer.validation.annotation.Longitude; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Thymeleaf controller for the hourly/daily forecast page of one of the @@ -29,6 +35,8 @@ public class ForecastController { private final WeatherApiService weatherApiService; private final LocationService locationService; private final UnitConverter unitConverter; + private final MessageSource messageSource; + private final ObjectMapper objectMapper; /** * Renders the forecast page for the saved location at the given @@ -51,6 +59,8 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude, log.info("Forecast retrieved for location={} (user={})", locationName, user.getUsername()); + model.addAttribute("latitude", latitude); + model.addAttribute("longitude", longitude); model.addAttribute("locationName", locationName); model.addAttribute("hourlyForecast", hourlyForecast); model.addAttribute("dailyForecast", dailyForecast); @@ -58,8 +68,30 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude, model.addAttribute("login", user.getFullName()); model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits())); model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits())); + model.addAttribute("conditionLabelsJson", buildConditionLabelsJson()); return "forecast"; } + /** + * Maps every {@link WeatherCondition} to its localized {@code weather-condition.*} + * label, serialized as JSON, so {@code live-forecast.js} can translate the raw + * enum values pushed over the socket without duplicating + * {@code messages.properties} in JavaScript. + */ + private String buildConditionLabelsJson() { + Map labels = new LinkedHashMap<>(); + for (WeatherCondition condition : WeatherCondition.values()) { + labels.put(condition.name(), + messageSource.getMessage("weather-condition." + condition.name(), null, LocaleContextHolder.getLocale())); + } + + try { + return objectMapper.writeValueAsString(labels).replace(" locations = locationService.getByUserId(user.getId()); + log.info("Map page requested by user '{}', {} saved location(s)", user.getUsername(), locations.size()); + + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + + model.addAttribute("login", user.getFullName()); + model.addAttribute("locations", locations); + model.addAttribute("mapDataJson", buildMapDataJson(locations, csrfToken)); + return "map"; + } + + private String buildMapDataJson(List locations, CsrfToken csrfToken) { + Map layerLabels = new LinkedHashMap<>(); + layerLabels.put("precipitation", messageSource.getMessage("map.layer.precipitation", null, LocaleContextHolder.getLocale())); + layerLabels.put("clouds", messageSource.getMessage("map.layer.clouds", null, LocaleContextHolder.getLocale())); + layerLabels.put("temperature", messageSource.getMessage("map.layer.temperature", null, LocaleContextHolder.getLocale())); + layerLabels.put("wind", messageSource.getMessage("map.layer.wind", null, LocaleContextHolder.getLocale())); + + Map labels = new LinkedHashMap<>(); + labels.put("forecast", messageSource.getMessage("map.popup.forecast", null, LocaleContextHolder.getLocale())); + labels.put("addLocation", messageSource.getMessage("map.popup.add-location", null, LocaleContextHolder.getLocale())); + labels.put("locationNamePlaceholder", messageSource.getMessage("map.popup.location-name-placeholder", null, LocaleContextHolder.getLocale())); + + Map data = new LinkedHashMap<>(); + data.put("locations", locations); + data.put("layerLabels", layerLabels); + data.put("labels", labels); + data.put("forecastUrl", "/forecast"); + data.put("addLocationUrl", "/search/add"); + + data.put("csrfParam", csrfToken.getParameterName()); + data.put("csrfToken", csrfToken.getToken()); + + try { + return objectMapper.writeValueAsString(data).replace(" + * {@code redirectTo} lets a caller (e.g. the map page's "add location" + * form) choose where the request lands afterward instead of always + * going to the dashboard; it's validated by + * {@link SafeRedirectUtils} and falls back to {@code /} if missing or + * unsafe, so it can't be used to redirect off-site. */ @PostMapping("/search/add") public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocationDto, + @RequestParam(value = "redirectTo", required = false) String redirectTo, @AuthenticationPrincipal SecUser secUser, BindingResult bindingResult, RedirectAttributes redirectAttributes) { + String target = SafeRedirectUtils.sanitize(redirectTo, "/"); + addLocationDto.setUserId(secUser.getId()); validator.validate(addLocationDto, bindingResult); @@ -78,13 +89,13 @@ public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocat log.info("Failed to add location for user '{}': {}", secUser.getUsername(), errorMessages); redirectAttributes.addFlashAttribute("errorMessages", errorMessages); - return "redirect:/"; + return "redirect:" + target; } locationService.add(addLocationDto); log.info("Location '{}' added successfully for user '{}'", addLocationDto.getName(), secUser.getUsername()); redirectAttributes.addFlashAttribute("successMessage", "Location added successfully!"); - return "redirect:/"; + return "redirect:" + target; } } diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java b/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java new file mode 100644 index 0000000..7032bae --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java @@ -0,0 +1,22 @@ +package com.weatherviewer.dto.ws; + +import com.weatherviewer.dto.WeatherDto; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.util.UUID; + +/** Current weather for one saved location, as pushed to a live dashboard subscriber. */ +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +public class DashboardLocationWeather { + + private UUID locationId; + private String locationName; + private WeatherDto weather; + +} diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java b/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java new file mode 100644 index 0000000..ffa94bc --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java @@ -0,0 +1,16 @@ +package com.weatherviewer.dto.ws; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Sent by the client (STOMP SEND to {@code /app/dashboard.subscribe}) after connecting, or whenever it re-sorts/re-pages the dashboard in place. */ +@Getter +@Setter +@NoArgsConstructor +public class DashboardSubscribeRequest { + + private String sort; + private Integer page; + +} diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java b/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java new file mode 100644 index 0000000..4fdb207 --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java @@ -0,0 +1,26 @@ +package com.weatherviewer.dto.ws; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * One live-update tick for a dashboard subscriber: weather for every + * location on their currently-viewed page, plus the names of any locations + * whose fetch failed this tick (mirrors {@code HomeController}'s + * {@code errorMessages} model attribute, so the client can render the same + * "temporarily unavailable" state it would get from a full page reload). + */ +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +public class DashboardUpdateMessage { + + private List locations; + private List unavailableLocationNames; + +} diff --git a/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java b/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java new file mode 100644 index 0000000..3588616 --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java @@ -0,0 +1,16 @@ +package com.weatherviewer.dto.ws; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Sent by the client (STOMP SEND to {@code /app/forecast.subscribe}) after connecting to the forecast page. */ +@Getter +@Setter +@NoArgsConstructor +public class ForecastSubscribeRequest { + + private Double lat; + private Double lon; + +} diff --git a/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java b/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java new file mode 100644 index 0000000..2f44ab4 --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java @@ -0,0 +1,21 @@ +package com.weatherviewer.dto.ws; + +import com.weatherviewer.dto.WeatherDto; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.util.List; + +/** One live-update tick for a forecast-page subscriber: refreshed hourly and daily forecast entries. */ +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +public class ForecastUpdateMessage { + + private List hourlyForecast; + private List dailyForecast; + +} diff --git a/src/main/java/com/weatherviewer/rest/MapTileController.java b/src/main/java/com/weatherviewer/rest/MapTileController.java new file mode 100644 index 0000000..6205866 --- /dev/null +++ b/src/main/java/com/weatherviewer/rest/MapTileController.java @@ -0,0 +1,53 @@ +package com.weatherviewer.rest; + +import com.weatherviewer.service.integration.MapTileLayer; +import com.weatherviewer.service.integration.WeatherTileClient; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.Optional; + +/** + * Proxies OpenWeatherMap's map-tile endpoints for the {@code /map} page. + *

+ * Without this, {@code map.html} would have to request tiles directly + * from OpenWeatherMap in the browser, which means putting the API key + * (OWM's tile API is authenticated via an {@code appid} query parameter, + * same as every other OWM endpoint this app calls) directly into a URL + * visible in the page's network requests. Routing through here instead + * keeps the key server-side, same as every other OpenWeatherMap call in + * this application. + *

+ * {@code layer} is resolved against {@link MapTileLayer}'s whitelist + * before anything is fetched — an unrecognized value is rejected as a bad + * request rather than passed through to OpenWeatherMap. + */ +@RestController +@RequiredArgsConstructor +public class MapTileController { + + private final WeatherTileClient weatherTileClient; + + @GetMapping(value = "/map/tiles/{layer}/{z}/{x}/{y}", produces = MediaType.IMAGE_PNG_VALUE) + public ResponseEntity tile(@PathVariable String layer, + @PathVariable int z, + @PathVariable int x, + @PathVariable int y) { + Optional resolvedLayer = MapTileLayer.fromRequestValue(layer); + if (resolvedLayer.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + byte[] tile = weatherTileClient.fetchTile(resolvedLayer.get(), z, x, y); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic()) + .body(tile); + } + +} diff --git a/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java new file mode 100644 index 0000000..236f1f1 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java @@ -0,0 +1,42 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.rest.MapTileController; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Whitelist of OpenWeatherMap map-tile overlays exposed via + * {@link MapTileController} for the + * {@code /map} page. + *

+ * The enum name (lowercased) is what {@code map.html} requests in the tile + * URL path; {@link #getOwmCode()} is OpenWeatherMap's own layer identifier, + * used only when building the upstream request. Resolving through this + * whitelist — rather than passing whatever path segment the client sent + * straight through to OpenWeatherMap — keeps the proxy from being usable + * to reach arbitrary OpenWeatherMap tile endpoints. + */ +@Getter +public enum MapTileLayer { + + PRECIPITATION("precipitation_new"), + CLOUDS("clouds_new"), + TEMPERATURE("temp_new"), + WIND("wind_new"); + + private final String owmCode; + + MapTileLayer(String owmCode) { + this.owmCode = owmCode; + } + + /** Resolves a request-path layer segment (case-insensitive) to a whitelisted layer, or empty if it doesn't match one. */ + public static Optional fromRequestValue(String value) { + return Arrays.stream(values()) + .filter(layer -> layer.name().equalsIgnoreCase(value)) + .findFirst(); + } + +} diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java new file mode 100644 index 0000000..2413e9e --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java @@ -0,0 +1,88 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; +import com.weatherviewer.rest.MapTileController; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.regex.Pattern; + +/** + * Thin HTTP client for OpenWeatherMap's map-tile endpoints (precipitation, + * clouds, temperature, and wind overlays for the {@code /map} page), used + * by {@link MapTileController}. + *

+ * Deliberately kept separate from {@link WeatherApiClient}: tiles are a + * different traffic shape (a single pan/zoom can request dozens at once), + * non-critical (a missing tile just doesn't render, unlike a failed + * weather lookup), and cacheable for a long time — not worth a circuit + * breaker of its own, so this only gets a lightweight retry plus a cache. + *

+ * Unlike {@link WeatherApiClient}/{@link WeatherApiCache}, retry and + * caching live on the same method here rather than being split across two + * classes — safe because both are still applied via an external Spring + * proxy call (from {@code MapTileController}), so there's no self-invocation + * concern, and one client/cache method is simple enough not to need the + * extra separation. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class WeatherTileClient { + + private static final Pattern APPID_PATTERN = Pattern.compile("(?i)([?&]appid=)[^&]*"); + + private final RestClient restClient; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.tile.base.url}") + private String tileBaseUrl; + + /** + * Fetches a single PNG tile for the given layer/zoom/coordinates. + * Cached by {@code mapTileCache} (see {@code application.properties} + * for its TTL) since the same tile is requested repeatedly across + * users and across pans/zooms that revisit the same area. + */ + @Retry(name = "mapTile") + @Cacheable("mapTileCache") + public byte[] fetchTile(MapTileLayer layer, int z, int x, int y) { + String url = UriComponentsBuilder + .fromUri(URI.create(tileBaseUrl + "/" + layer.getOwmCode() + "/" + z + "/" + x + "/" + y + ".png")) + .queryParam("appid", apiKey) + .build() + .toUriString(); + + try { + byte[] tile = restClient.get() + .uri(URI.create(url)) + .retrieve() + .body(byte[].class); + log.debug("Fetched map tile layer={} z={} x={} y={}", layer, z, x, y); + return tile; + } catch (RestClientResponseException e) { + log.warn("Map tile API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url)); + boolean retryable = e.getStatusCode() == null || !e.getStatusCode().is4xxClientError(); + throw new ExternalHttpCallException("Map tile API error: " + e.getStatusCode(), retryable); + } catch (Exception e) { + log.warn("Map tile fetch failed due to network or connection issues for URL: {}", maskApiKey(url)); + throw new ExternalHttpCallException("Map tile fetch failed due to network or connection issues"); + } + } + + /** Masks the {@code appid} query parameter so the API key never reaches application logs. */ + private static String maskApiKey(String url) { + return APPID_PATTERN.matcher(url).replaceAll("$1***"); + } + +} diff --git a/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java new file mode 100644 index 0000000..52b6696 --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java @@ -0,0 +1,23 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.model.enums.UnitSystem; + +import java.util.UUID; + +/** + * One client's live-dashboard subscription: which user, in which unit + * system, viewing which sorted/paginated slice of their saved locations. + * Mirrors the parameters {@code HomeController#home} takes from the query + * string, so the scheduler can recompute exactly the same page. + * + * @param sessionId the STOMP session that registered this subscription + * @param userId owner of the dashboard + * @param username owner's username (email) - the STOMP user-destination principal name + * @param units owner's preferred display units, applied to pushed weather + * @param sort dashboard sort key ({@code date}, {@code nameAsc}, {@code nameDesc}, {@code favoriteFirst}, {@code favoritesOnly}) + * @param page 0-based dashboard page number + */ +public record DashboardSubscription(String sessionId, UUID userId, + String username, UnitSystem units, + String sort, int page) { +} diff --git a/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java new file mode 100644 index 0000000..1d1ff90 --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java @@ -0,0 +1,23 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.model.enums.UnitSystem; + +import java.util.UUID; + +/** + * One client's live-forecast subscription: which user, in which unit + * system, watching the hourly/daily forecast for a single coordinate pair. + * Mirrors the parameters {@code ForecastController#getForecast} takes from + * the query string. + * + * @param sessionId the STOMP session that registered this subscription + * @param userId owner of the forecast page (used to re-verify the location is still theirs) + * @param username owner's username (email) - the STOMP user-destination principal name + * @param units owner's preferred display units, applied to pushed weather + * @param latitude location latitude + * @param longitude location longitude + */ +public record ForecastSubscription(String sessionId, UUID userId, + String username, UnitSystem units, + double latitude, double longitude) { +} diff --git a/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java new file mode 100644 index 0000000..493fa7b --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java @@ -0,0 +1,150 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.dto.LocationDto; +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.ws.DashboardLocationWeather; +import com.weatherviewer.dto.ws.DashboardUpdateMessage; +import com.weatherviewer.dto.ws.ForecastUpdateMessage; +import com.weatherviewer.exception.notfound.LocationNotFoundException; +import com.weatherviewer.service.LocationService; +import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.service.helper.UnitConverter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; + +/** + * Drives every connected client's live weather feed. + *

+ * On a fixed schedule, this re-derives exactly what each active + * {@link DashboardSubscription}/{@link ForecastSubscription} should + * currently be showing (same lookups {@code HomeController} and + * {@code ForecastController} do for a full page load) and pushes it to + * that user's private STOMP queue. Weather fetches for the individual + * subscriptions are composed as a single {@link Flux} with bounded + * concurrency, so one broadcast tick can't fan out an unbounded burst of + * calls into {@link WeatherApiService} (which mostly resolves from the + * Redis-backed cache anyway, but a cache stampede past the TTL boundary is + * still worth capping). Each subscription's fetch/push is isolated with + * {@code onErrorResume}, so one user's failing/slow location can't stall or + * cancel the whole tick. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class WeatherLiveUpdateScheduler { + + private final WeatherSubscriptionRegistry registry; + private final LocationService locationService; + private final WeatherApiService weatherApiService; + private final UnitConverter unitConverter; + private final SimpMessagingTemplate messagingTemplate; + private final ExecutorService weatherFetchExecutor; + + @Value("${location.dashboard.page-size:12}") + private int dashboardPageSize; + + @Value("${weather.live.max-concurrent-fetches:16}") + private int maxConcurrentFetches; + + /** + * Runs one broadcast tick. {@code fixedDelayString} (not fixed-rate) so + * that if a tick ever runs long - a slow OpenWeatherMap response, a + * burst of subscribers - the next tick is scheduled relative to when + * this one actually finished, instead of piling up overlapping ticks. + */ + @Scheduled(fixedDelayString = "${weather.live.push-interval-ms:60000}") + public void broadcast() { + Scheduler fetchScheduler = Schedulers.fromExecutor(weatherFetchExecutor); + + Flux dashboardTicks = Flux.fromIterable(registry.dashboardSubscriptions()) + .flatMap(subscription -> pushDashboardUpdate(subscription, fetchScheduler), maxConcurrentFetches); + + Flux forecastTicks = Flux.fromIterable(registry.forecastSubscriptions()) + .flatMap(subscription -> pushForecastUpdate(subscription, fetchScheduler), maxConcurrentFetches); + + Flux.merge(dashboardTicks, forecastTicks).then().block(); + } + + private Mono pushDashboardUpdate(DashboardSubscription subscription, Scheduler fetchScheduler) { + return Mono.fromRunnable(() -> doPushDashboardUpdate(subscription)) + .subscribeOn(fetchScheduler) + .then() + .onErrorResume(ex -> { + log.warn("Live dashboard push failed for user={}: {}", subscription.username(), ex.getMessage()); + return Mono.empty(); + }); + } + + private void doPushDashboardUpdate(DashboardSubscription subscription) { + Page locationPage = locationService.getByUserIdSorted( + subscription.userId(), subscription.sort(), PageRequest.of(subscription.page(), dashboardPageSize)); + + List updates = new ArrayList<>(); + List unavailable = new ArrayList<>(); + + for (LocationDto location : locationPage.getContent()) { + try { + WeatherDto weather = unitConverter.toDisplayUnits( + weatherApiService.getWeatherByLocation(location), subscription.units()); + updates.add(new DashboardLocationWeather() + .setLocationId(location.getId()) + .setLocationName(location.getName()) + .setWeather(weather)); + } catch (RuntimeException ex) { + log.debug("Live weather fetch failed for location '{}' (user={}): {}", + location.getName(), subscription.username(), ex.getMessage()); + unavailable.add(location.getName()); + } + } + + if (updates.isEmpty() && unavailable.isEmpty()) { + return; + } + + messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/dashboard", + new DashboardUpdateMessage().setLocations(updates).setUnavailableLocationNames(unavailable)); + } + + private Mono pushForecastUpdate(ForecastSubscription subscription, Scheduler fetchScheduler) { + return Mono.fromRunnable(() -> doPushForecastUpdate(subscription)) + .subscribeOn(fetchScheduler) + .then() + .onErrorResume(ex -> { + log.warn("Live forecast push failed for user={}: {}", subscription.username(), ex.getMessage()); + return Mono.empty(); + }); + } + + private void doPushForecastUpdate(ForecastSubscription subscription) { + try { + locationService.getByCoordinatesAndUserId(subscription.latitude(), subscription.longitude(), subscription.userId()); + } catch (LocationNotFoundException ex) { + log.debug("Live forecast subscription for user={} no longer owns lat={}, lon={}; skipping tick", + subscription.username(), subscription.latitude(), subscription.longitude()); + return; + } + + List hourly = unitConverter.toDisplayUnits( + weatherApiService.getHourlyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units()); + List daily = unitConverter.toDisplayUnits( + weatherApiService.getDailyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units()); + + messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/forecast", + new ForecastUpdateMessage().setHourlyForecast(hourly).setDailyForecast(daily)); + } + +} diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java new file mode 100644 index 0000000..a0aa8f7 --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java @@ -0,0 +1,73 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.dto.ws.DashboardSubscribeRequest; +import com.weatherviewer.dto.ws.ForecastSubscribeRequest; +import com.weatherviewer.security.SecUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; + +import java.security.Principal; +import java.util.Set; + +/** + * STOMP message handlers for {@code /app/**} destinations. These don't + * return a value (no {@code @SendTo}) - registering a subscription here + * just updates {@link WeatherSubscriptionRegistry}; the actual weather + * pushes are sent later, out-of-band, by {@link WeatherLiveUpdateScheduler}. + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class WeatherSocketController { + + private static final Set VALID_SORTS = Set.of("date", "nameAsc", "nameDesc", "favoriteFirst", "favoritesOnly"); + + private final WeatherSubscriptionRegistry registry; + + /** Registers (or replaces) this session's live dashboard subscription for the given sort/page. */ + @MessageMapping("/dashboard.subscribe") + public void subscribeDashboard(@Payload DashboardSubscribeRequest request, Principal principal, + SimpMessageHeaderAccessor headerAccessor) { + SecUser user = extractUser(principal); + String sessionId = headerAccessor.getSessionId(); + if (user == null || sessionId == null) { + log.warn("Ignoring dashboard.subscribe with no authenticated principal or session id"); + return; + } + + String sort = request.getSort() != null && VALID_SORTS.contains(request.getSort()) ? request.getSort() : "date"; + int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : 0; + + registry.registerDashboard(new DashboardSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(), sort, page)); + log.debug("Live dashboard subscription registered: user={}, sort={}, page={}", user.getUsername(), sort, page); + } + + /** Registers (or replaces) this session's live forecast subscription for the given coordinates. */ + @MessageMapping("/forecast.subscribe") + public void subscribeForecast(@Payload ForecastSubscribeRequest request, Principal principal, + SimpMessageHeaderAccessor headerAccessor) { + SecUser user = extractUser(principal); + String sessionId = headerAccessor.getSessionId(); + if (user == null || sessionId == null || request.getLat() == null || request.getLon() == null) { + log.warn("Ignoring forecast.subscribe with missing principal, session id, or coordinates"); + return; + } + + registry.registerForecast(new ForecastSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(), + request.getLat(), request.getLon())); + log.debug("Live forecast subscription registered: user={}, lat={}, lon={}", user.getUsername(), request.getLat(), request.getLon()); + } + + private SecUser extractUser(Principal principal) { + if (principal instanceof Authentication authentication && authentication.getPrincipal() instanceof SecUser secUser) { + return secUser; + } + return null; + } + +} diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java new file mode 100644 index 0000000..f9dc958 --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java @@ -0,0 +1,28 @@ +package com.weatherviewer.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +/** + * Removes a session's live-update subscription (dashboard or forecast) as + * soon as its WebSocket connection closes - tab closed, page navigated + * away, network drop, etc. - so {@link WeatherLiveUpdateScheduler} never + * wastes a fetch/push on a client that's no longer listening. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class WeatherSocketEventListener { + + private final WeatherSubscriptionRegistry registry; + + @EventListener + public void onSessionDisconnect(SessionDisconnectEvent event) { + registry.remove(event.getSessionId()); + log.debug("WebSocket session {} disconnected; live-update subscription removed", event.getSessionId()); + } + +} diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java new file mode 100644 index 0000000..b85c321 --- /dev/null +++ b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java @@ -0,0 +1,57 @@ +package com.weatherviewer.websocket; + +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which connected WebSocket sessions want live weather pushes, and + * for what (a paginated/sorted dashboard view, or a single forecast-page + * location). + *

+ * Keyed by STOMP session ID rather than user ID: a user could have the + * dashboard open in one tab and a forecast page in another, each getting + * its own independent live feed. A session holds at most one subscription + * of each kind at a time - registering a new one for a session replaces + * whatever that session was previously subscribed to. + *

+ * Entries are removed by {@link WeatherSocketEventListener} when the + * underlying WebSocket session disconnects, so this never accumulates + * subscriptions for clients that have navigated away or closed the tab. + */ +@Component +public class WeatherSubscriptionRegistry { + + private final Map dashboardSubscriptions = new ConcurrentHashMap<>(); + private final Map forecastSubscriptions = new ConcurrentHashMap<>(); + + public void registerDashboard(DashboardSubscription subscription) { + forecastSubscriptions.remove(subscription.sessionId()); + dashboardSubscriptions.put(subscription.sessionId(), subscription); + } + + public void registerForecast(ForecastSubscription subscription) { + dashboardSubscriptions.remove(subscription.sessionId()); + forecastSubscriptions.put(subscription.sessionId(), subscription); + } + + /** Removes any subscription (dashboard or forecast) held by this session, e.g. on disconnect. */ + public void remove(String sessionId) { + if (sessionId == null) { + return; + } + dashboardSubscriptions.remove(sessionId); + forecastSubscriptions.remove(sessionId); + } + + public Collection dashboardSubscriptions() { + return dashboardSubscriptions.values(); + } + + public Collection forecastSubscriptions() { + return forecastSubscriptions.values(); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 37012af..e09d24b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -61,6 +61,10 @@ scalar.path=/scalar location.max-per-user=${LOCATION_MAX_PER_USER:100} location.dashboard.page-size=${LOCATION_DASHBOARD_PAGE_SIZE:4} +# --- Live weather updates (WebSocket/STOMP) --- +weather.live.push-interval-ms=${WEATHER_LIVE_PUSH_INTERVAL_MS:60000} +weather.live.max-concurrent-fetches=${WEATHER_LIVE_MAX_CONCURRENT_FETCHES:16} + # --- Weather API (OpenWeatherMap) --- weather.dashboard.fetch-pool-size=${WEATHER_DASHBOARD_FETCH_POOL_SIZE:20} weather.dashboard.fetch-timeout-ms=${WEATHER_DASHBOARD_FETCH_TIMEOUT_MS:4000} @@ -71,6 +75,7 @@ geo.api.url.suffix=/geo/1.0/direct weather.api.key=${WEATHER_API_KEY} weather.api.connect-timeout-ms=${WEATHER_API_CONNECT_TIMEOUT_MS:3000} weather.api.read-timeout-ms=${WEATHER_API_READ_TIMEOUT_MS:5000} +weather.tile.base.url=https://tile.openweathermap.org/map # --- Resilience (retry + circuit breaker around the OpenWeatherMap client) --- resilience4j.retry.instances.weatherApi.max-attempts=3 @@ -87,6 +92,10 @@ resilience4j.circuitbreaker.instances.weatherApi.wait-duration-in-open-state=30s resilience4j.circuitbreaker.instances.weatherApi.permitted-number-of-calls-in-half-open-state=5 resilience4j.circuitbreaker.instances.weatherApi.automatic-transition-from-open-to-half-open-enabled=true +# --- Resilience (light retry for map tiles; see WeatherTileClient) --- +resilience4j.retry.instances.mapTile.max-attempts=2 +resilience4j.retry.instances.mapTile.wait-duration=300ms + # --- Resilience (retry around SMTP sends; see MailServiceImpl) --- resilience4j.retry.instances.mail.max-attempts=3 resilience4j.retry.instances.mail.wait-duration=500ms @@ -160,5 +169,9 @@ rate-limit.rules[4].path-prefix=/api/v1/users rate-limit.rules[4].limit=60 rate-limit.rules[4].window-seconds=60 +rate-limit.rules[5].path-prefix=/map/tiles +rate-limit.rules[5].limit=300 +rate-limit.rules[5].window-seconds=60 + management.tracing.sampling.probability=1.0 logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}] \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 75e2a6b..334f9fd 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -6,6 +6,7 @@ header.sign-up=Sign up header.sign-out=Sign out header.theme.dark-mode=Switch to dark mode header.language=Language +header.map=Map # --- Sign-in page (sign-in.html) --- signin.title=Sign in @@ -89,6 +90,17 @@ weather-condition.CLEAR=Clear weather-condition.CLOUDS=Clouds weather-condition.UNDEFINED=No data +# --- Map page (map.html) --- +map.title=Map +map.instructions=Your saved locations, plus an optional weather overlay. Use the layers control (top right) to switch overlays. +map.layer.precipitation=Precipitation +map.layer.clouds=Clouds +map.layer.temperature=Temperature +map.layer.wind=Wind +map.popup.forecast=Forecast +map.popup.add-location=Add location +map.popup.location-name-placeholder=Location name + # --- Profile page (profile.html) --- profile.title=Account Settings profile.first-name.placeholder=First Name diff --git a/src/main/resources/messages_uk.properties b/src/main/resources/messages_uk.properties index d6836ba..dfc9229 100644 --- a/src/main/resources/messages_uk.properties +++ b/src/main/resources/messages_uk.properties @@ -6,6 +6,7 @@ header.sign-up=Реєстрація header.sign-out=Вийти header.theme.dark-mode=Перемкнути на темну тему header.language=Мова +header.map=Карта # --- Sign-in page (sign-in.html) --- signin.title=Вхід @@ -88,6 +89,17 @@ weather-condition.CLEAR=Ясно weather-condition.CLOUDS=Хмарно weather-condition.UNDEFINED=Немає даних +# --- Map page (map.html) --- +map.title=Карта +map.instructions=Ваші збережені місця та необов'язковий шар погоди. Використовуйте перемикач шарів (угорі праворуч), щоб змінити шар. +map.layer.precipitation=Опади +map.layer.clouds=Хмарність +map.layer.temperature=Температура +map.layer.wind=Вітер +map.popup.forecast=Прогноз +map.popup.add-location=Додати місце +map.popup.location-name-placeholder=Назва місця + # --- Profile page (profile.html) --- profile.title=Налаштування акаунта profile.first-name.placeholder=Ім'я diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index dfa4004..0bcc569 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -59,4 +59,53 @@ body { .theme-toggle-btn:hover { background-color: var(--bs-secondary-bg); +} + +/* Leaflet map (map.html) — leaflet.css hardcodes light backgrounds for its + own chrome (popups, zoom buttons, layer control, attribution) and loads + after this file, so these need !important to win the cascade. Using + Bootstrap's theme-aware CSS variables (rather than scoping under + [data-bs-theme="dark"]) means one rule set covers both themes, the same + way .theme-toggle-btn does above. */ +.leaflet-popup-content-wrapper, +.leaflet-popup-tip, +.leaflet-bar a, +.leaflet-control-layers, +.leaflet-control-layers-expanded { + background-color: var(--bs-body-bg) !important; + color: var(--bs-body-color) !important; +} + +.leaflet-bar a { + border-color: var(--bs-border-color) !important; +} + +.leaflet-bar a:hover { + background-color: var(--bs-secondary-bg) !important; +} + +.leaflet-popup-close-button { + color: var(--bs-body-color) !important; +} + +.leaflet-control-attribution { + background-color: var(--bs-body-bg) !important; + color: var(--bs-secondary-color) !important; +} + +.leaflet-control-attribution a { + color: var(--bs-link-color) !important; +} + +[data-bs-theme="dark"] .base-map-tiles { + filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%); +} + +.weather-card, .card { + transition: box-shadow 0.4s ease-out, border-color 0.4s ease-out; +} + +.live-updated { + box-shadow: 0 0 0 2px var(--bs-primary) !important; + border-color: var(--bs-primary) !important; } \ No newline at end of file diff --git a/src/main/resources/static/js/life-forecast.js b/src/main/resources/static/js/life-forecast.js new file mode 100644 index 0000000..42d33a0 --- /dev/null +++ b/src/main/resources/static/js/life-forecast.js @@ -0,0 +1,92 @@ +document.addEventListener('DOMContentLoaded', () => { + const root = document.getElementById('forecastLiveRoot'); + if (!root || typeof StompJs === 'undefined') { + return; + } + + const lat = parseFloat(root.dataset.lat); + const lon = parseFloat(root.dataset.lon); + const tempSymbol = root.dataset.tempSymbol; + const locale = root.dataset.locale || undefined; + + /** enum name (e.g. "RAIN") -> localized weather-condition.* label, from ForecastController. */ + const conditionLabels = JSON.parse(document.getElementById('forecast-condition-labels').textContent || '{}'); + + const dayDateFormatter = new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'long' }); + const dayNameFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long' }); + const hourFormatter = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12: false }); + + function set(card, field, text) { + const el = card.querySelector(`[data-field="${field}"]`); + if (el) { + el.textContent = text; + } + } + + function setIcon(card, weather) { + const icon = card.querySelector('[data-field="icon"]'); + if (icon) { + icon.src = `/images/${weather.weatherCondition}-${weather.timeOfDay}.svg`; + icon.alt = `${weather.weatherCondition} ${weather.timeOfDay}`; + } + } + + function setDescription(card, weather) { + const el = card.querySelector('[data-field="description"]'); + if (el) { + el.textContent = conditionLabels[weather.weatherCondition] || weather.weatherCondition; + el.dataset.condition = weather.weatherCondition; + } + } + + function flash(card) { + card.classList.add('live-updated'); + setTimeout(() => card.classList.remove('live-updated'), 1500); + } + + function patchHourlyCard(card, weather) { + set(card, 'time', hourFormatter.format(new Date(weather.date))); + set(card, 'temperature', `${weather.temperature}${tempSymbol}`); + setIcon(card, weather); + setDescription(card, weather); + flash(card); + } + + function patchDailyCard(card, weather) { + const date = new Date(weather.date); + set(card, 'dayDate', dayDateFormatter.format(date)); + set(card, 'dayName', dayNameFormatter.format(date)); + set(card, 'temperature', `${weather.temperature}${tempSymbol}`); + set(card, 'temperatureMinimum', `${weather.temperatureMinimum}${tempSymbol}`); + setIcon(card, weather); + setDescription(card, weather); + flash(card); + } + + function patchForecast(update) { + (update.hourlyForecast || []).slice(0, 5).forEach((weather, index) => { + const card = root.querySelector(`[data-hourly-index="${index}"]`); + if (card) { + patchHourlyCard(card, weather); + } + }); + + (update.dailyForecast || []).forEach((weather, index) => { + const card = root.querySelector(`[data-daily-index="${index}"]`); + if (card) { + patchDailyCard(card, weather); + } + }); + } + + LiveWeather.connect(client => { + client.subscribe('/user/queue/forecast', message => { + patchForecast(JSON.parse(message.body)); + }); + + client.publish({ + destination: '/app/forecast.subscribe', + body: JSON.stringify({ lat, lon }), + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/live-dashboard.js b/src/main/resources/static/js/live-dashboard.js new file mode 100644 index 0000000..66f4cbb --- /dev/null +++ b/src/main/resources/static/js/live-dashboard.js @@ -0,0 +1,76 @@ +document.addEventListener('DOMContentLoaded', () => { + const root = document.getElementById('dashboardLiveRoot'); + if (!root || typeof StompJs === 'undefined') { + return; + } + + const sort = root.dataset.sort; + const page = parseInt(root.dataset.page, 10) || 0; + const tempSymbol = root.dataset.tempSymbol; + const windUnit = root.dataset.windUnit; + const i18n = { + feelsLike: root.dataset.i18nFeelsLike, + min: root.dataset.i18nMin, + max: root.dataset.i18nMax, + clouds: root.dataset.i18nClouds, + wind: root.dataset.i18nWind, + humidity: root.dataset.i18nHumidity, + pressure: root.dataset.i18nPressure, + }; + + /** Minimal {0}/{1}/{2}-style substitution, matching the java.text.MessageFormat patterns from messages.properties. */ + function formatMessage(pattern, ...args) { + return pattern.replace(/\{(\d+)}/g, (_, index) => args[index]); + } + + function formatTime(epochMillis) { + const date = new Date(epochMillis); + const pad = n => String(n).padStart(2, '0'); + return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + } + + function patchCard(card, weather) { + const set = (field, text) => { + const el = card.querySelector(`[data-field="${field}"]`); + if (el) { + el.textContent = text; + } + }; + + set('time', formatTime(weather.date)); + set('temperature', `${weather.temperature}${tempSymbol}`); + set('feelsLike', formatMessage(i18n.feelsLike, weather.temperatureFeelsLike, tempSymbol)); + set('description', weather.description.charAt(0).toUpperCase() + weather.description.slice(1)); + set('min', formatMessage(i18n.min, weather.temperatureMinimum, tempSymbol)); + set('clouds', formatMessage(i18n.clouds, weather.cloudiness)); + set('wind', formatMessage(i18n.wind, weather.windDirection, weather.windSpeed, windUnit)); + set('max', formatMessage(i18n.max, weather.temperatureMaximum, tempSymbol)); + set('humidity', formatMessage(i18n.humidity, weather.humidity)); + set('pressure', formatMessage(i18n.pressure, weather.pressure)); + + const icon = card.querySelector('[data-field="icon"]'); + if (icon) { + icon.src = `/images/${weather.weatherCondition}-${weather.timeOfDay}.svg`; + } + + card.classList.add('live-updated'); + setTimeout(() => card.classList.remove('live-updated'), 1500); + } + + LiveWeather.connect(client => { + client.subscribe('/user/queue/dashboard', message => { + const update = JSON.parse(message.body); + (update.locations || []).forEach(location => { + const card = root.querySelector(`[data-location-id="${location.locationId}"]`); + if (card && location.weather) { + patchCard(card, location.weather); + } + }); + }); + + client.publish({ + destination: '/app/dashboard.subscribe', + body: JSON.stringify({ sort, page }), + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/live-weather.js b/src/main/resources/static/js/live-weather.js new file mode 100644 index 0000000..dcd394d --- /dev/null +++ b/src/main/resources/static/js/live-weather.js @@ -0,0 +1,35 @@ +/** + * Thin wrapper around StompJs.Client for this app's live-weather feeds. + * + * One WebSocket connection per page (native ws/wss, no SockJS - see + * WebSocketConfig for why). Reconnects automatically with backoff and + * re-runs onConnect (which callers use to re-send their subscribe frame) + * every time, since the server-side subscription registry is in-memory and + * doesn't survive a dropped connection. + */ +const LiveWeather = (() => { + + function connect(onConnectCallback) { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const brokerUrl = `${protocol}://${window.location.host}/ws`; + + const client = new StompJs.Client({ + brokerURL: brokerUrl, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + }); + + client.onConnect = () => onConnectCallback(client); + + client.onStompError = frame => { + console.warn('Live weather STOMP error:', frame.headers && frame.headers.message); + }; + + client.activate(); + window.addEventListener('beforeunload', () => client.deactivate()); + return client; + } + + return { connect }; +})(); \ No newline at end of file diff --git a/src/main/resources/static/js/map-init.js b/src/main/resources/static/js/map-init.js new file mode 100644 index 0000000..38d79be --- /dev/null +++ b/src/main/resources/static/js/map-init.js @@ -0,0 +1,99 @@ +/* global L */ +(function () { + 'use strict'; + + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + var dataElement = document.getElementById('map-data'); + var data = dataElement + ? JSON.parse(dataElement.textContent) + : { locations: [], layerLabels: {}, labels: {}, forecastUrl: '/forecast', addLocationUrl: '/search/add' }; + + var locations = data.locations || []; + var layerLabels = data.layerLabels || {}; + var labels = data.labels || {}; + var forecastUrl = data.forecastUrl || '/forecast'; + var addLocationUrl = data.addLocationUrl || '/search/add'; + var csrfParam = data.csrfParam || '_csrf'; + var csrfToken = data.csrfToken || getCsrfToken(); + + function getCsrfToken() { + var match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/); + return match ? decodeURIComponent(match[1]) : ''; + } + + var DEFAULT_CENTER = [20, 0]; + var DEFAULT_ZOOM = 2; + + var map = L.map('weather-map').setView(DEFAULT_CENTER, DEFAULT_ZOOM); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: '© OpenStreetMap contributors', + className: 'base-map-tiles' + }).addTo(map); + + var overlayOptions = { opacity: 0.55, maxZoom: 18 }; + var overlays = {}; + overlays[layerLabels.precipitation] = L.tileLayer('/map/tiles/precipitation/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.clouds] = L.tileLayer('/map/tiles/clouds/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.temperature] = L.tileLayer('/map/tiles/temperature/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.wind] = L.tileLayer('/map/tiles/wind/{z}/{x}/{y}', overlayOptions); + + L.control.layers(null, overlays, { collapsed: false }).addTo(map); + + var markers = []; + locations.forEach(function (location) { + if (location.latitude == null || location.longitude == null) { + return; + } + + var forecastLink = '' + + escapeHtml(labels.forecast || 'Forecast') + ''; + var popupContent = '' + escapeHtml(location.name) + '
' + forecastLink; + + var marker = L.marker([location.latitude, location.longitude], { bubblingMouseEvents: false }) + .addTo(map) + .bindPopup(popupContent); + markers.push(marker); + }); + + if (markers.length > 0) { + var group = L.featureGroup(markers); + map.fitBounds(group.getBounds().pad(0.3)); + } + + map.on('click', function (e) { + var lat = e.latlng.lat; + var lng = e.latlng.lng; + + var formHtml = + '

' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
'; + + L.popup() + .setLatLng(e.latlng) + .setContent(formHtml) + .openOn(map); + }); +})(); \ No newline at end of file diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index b03839f..ffbe419 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -10,19 +10,23 @@
-
+
+

Hourly Forecast

-
-
+
+
-
-

- -

+
+

+ +

@@ -32,18 +36,19 @@

Daily Forecast

-
-
+
+
-
- +
+
-

-

- -

+

+

+ +

@@ -57,7 +62,10 @@

+ + + \ No newline at end of file diff --git a/src/main/resources/templates/header-component.html b/src/main/resources/templates/header-component.html index 635e0d3..81fedcb 100644 --- a/src/main/resources/templates/header-component.html +++ b/src/main/resources/templates/header-component.html @@ -11,6 +11,10 @@

Weather Viewer

+ + + -
+
-
+

Location

-
Time
@@ -98,17 +105,17 @@

Location

- weather-image
-

-

+

+

-

+

@@ -117,15 +124,15 @@

  • - +
  • - +
  • - +
  • - +
  • - +
  • - +
  • + + + \ No newline at end of file diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html new file mode 100644 index 0000000..ef86f3f --- /dev/null +++ b/src/main/resources/templates/map.html @@ -0,0 +1,83 @@ + + + + + + + + + + + +
    +
    +

    Map

    +

    Your saved locations, plus an optional weather overlay. Use the layers control (top right) to switch overlays.

    + +
    + +

    + Please add a location to view weather. You can do it by using the search bar above. +

    +
    +
    +
    + + + + + + + + + + + \ No newline at end of file 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 d7a0168..0000000 --- a/src/test/java/com/weatherviewer/WeatherViewerApplicationTests.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weatherviewer; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WeatherViewerApplicationTests { - - @Test - void contextLoads() { - } - - @Test - void mainMethodStartsApplication() { - WeatherViewerApplication.main(new String[] {}); - } - -} diff --git a/src/test/java/com/weatherviewer/config/WebSocketConfigTest.java b/src/test/java/com/weatherviewer/config/WebSocketConfigTest.java new file mode 100644 index 0000000..38b755e --- /dev/null +++ b/src/test/java/com/weatherviewer/config/WebSocketConfigTest.java @@ -0,0 +1,89 @@ +package com.weatherviewer.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.StompWebSocketEndpointRegistration; +import org.springframework.web.socket.server.HandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class WebSocketConfigTest { + + private final WebSocketConfig config = new WebSocketConfig(); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void registerStompEndpoints_registersWsEndpointWithAuthenticationHandshakeHandler() { + StompEndpointRegistry registry = mock(StompEndpointRegistry.class); + StompWebSocketEndpointRegistration registration = mock(StompWebSocketEndpointRegistration.class); + when(registry.addEndpoint("/ws")).thenReturn(registration); + when(registration.setHandshakeHandler(any())).thenReturn(registration); + + config.registerStompEndpoints(registry); + + verify(registry).addEndpoint("/ws"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HandshakeHandler.class); + verify(registration).setHandshakeHandler(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(WebSocketConfig.AuthenticationHandshakeHandler.class); + } + + @Test + void configureMessageBroker_enablesQueueBrokerWithAppAndUserPrefixes() { + MessageBrokerRegistry registry = mock(MessageBrokerRegistry.class); + + config.configureMessageBroker(registry); + + verify(registry).enableSimpleBroker("/queue"); + verify(registry).setApplicationDestinationPrefixes("/app"); + verify(registry).setUserDestinationPrefix("/user"); + } + + @Test + void determineUser_authenticatedSecurityContext_returnsAuthenticationAsPrincipal() { + Authentication authentication = new TestingAuthenticationToken("john@example.com", "hashed", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + WebSocketConfig.AuthenticationHandshakeHandler handler = new WebSocketConfig.AuthenticationHandshakeHandler(); + Principal result = handler.determineUser(null, null, Map.of()); + + assertThat(result).isSameAs(authentication); + } + + @Test + void determineUser_unauthenticatedSecurityContext_returnsNull() { + Authentication authentication = new TestingAuthenticationToken("john@example.com", "hashed"); + authentication.setAuthenticated(false); + SecurityContextHolder.getContext().setAuthentication(authentication); + + WebSocketConfig.AuthenticationHandshakeHandler handler = new WebSocketConfig.AuthenticationHandshakeHandler(); + Principal result = handler.determineUser(null, null, Map.of()); + + assertThat(result).isNull(); + } + + @Test + void determineUser_noAuthenticationInContext_returnsNull() { + SecurityContextHolder.clearContext(); + + WebSocketConfig.AuthenticationHandshakeHandler handler = new WebSocketConfig.AuthenticationHandshakeHandler(); + Principal result = handler.determineUser(null, null, Map.of()); + + assertThat(result).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java index 65a9963..cc46ce1 100644 --- a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java @@ -1,5 +1,7 @@ package com.weatherviewer.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.weatherviewer.dto.LocationDto; import com.weatherviewer.dto.WeatherDto; import com.weatherviewer.dto.enums.TimeOfDay; @@ -13,17 +15,17 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.MessageSource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; -import java.util.Date; -import java.util.List; -import java.util.Set; -import java.util.UUID; +import java.util.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -33,30 +35,34 @@ class ForecastControllerTest { @Autowired - MockMvc mockMvc; + private MockMvc mockMvc; + + @Autowired + private ForecastController forecastController; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private WeatherApiService weatherApiService; @MockitoBean - WeatherApiService weatherApiService; + private LocationService locationService; @MockitoBean - LocationService locationService; + private UnitConverter unitConverter; @MockitoBean - UnitConverter unitConverter; - - /** - * UnitConverter is real (unmocked) application logic in production; - * here it's stubbed to pass values through unchanged, since this test - * only needs to confirm ForecastController plumbs data into the model - * correctly, not that unit conversion math is right (that's covered - * separately). Without this, the mock would return null and break the - * existing hourlyForecast/dailyForecast assertions below. - */ + private MessageSource messageSource; + @BeforeEach - void stubUnitConverterPassThrough() { + void setup() { when(unitConverter.toDisplayUnits(anyList(), any())).thenAnswer(invocation -> invocation.getArgument(0)); when(unitConverter.temperatureSymbol(any())).thenReturn("°C"); when(unitConverter.windSpeedUnit(any())).thenReturn("m/s"); + + when(messageSource.getMessage(any(String.class), any(), any(Locale.class))) + .thenAnswer(inv -> "Label for " + inv.getArgument(0)); } private SecUser secUser() { @@ -94,16 +100,17 @@ private WeatherDto weatherDto() { } @Test - void getForecast_returns200AndView() throws Exception { + void getForecast_returns200AndViewAndPopulatesAllModelAttributes() throws Exception { SecUser user = secUser(); LocationDto location = locationDto(); + WeatherDto weather = weatherDto(); when(locationService.getByCoordinatesAndUserId(50.45, 30.52, user.getId())) .thenReturn(location); when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of(weatherDto())); + .thenReturn(List.of(weather)); when(weatherApiService.getDailyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of(weatherDto())); + .thenReturn(List.of(weather)); mockMvc.perform(get("/forecast") .with(user(user)) @@ -111,31 +118,47 @@ void getForecast_returns200AndView() throws Exception { .param("lon", "30.52")) .andExpect(status().isOk()) .andExpect(view().name("forecast")) - .andExpect(model().attributeExists("locationName", "hourlyForecast", "dailyForecast", "login")); + .andExpect(model().attribute("latitude", 50.45)) + .andExpect(model().attribute("longitude", 30.52)) + .andExpect(model().attribute("locationName", "Kyiv")) + .andExpect(model().attribute("hourlyForecast", List.of(weather))) + .andExpect(model().attribute("dailyForecast", List.of(weather))) + .andExpect(model().attribute("login", "John Doe")) + .andExpect(model().attribute("temperatureSymbol", "°C")) + .andExpect(model().attribute("windSpeedUnit", "m/s")) + .andExpect(model().attributeExists("conditionLabelsJson")); } @Test - void getForecast_addsCorrectLocationName() throws Exception { + void getForecast_whenObjectMapperFails_returnsEmptyJsonInModel() throws Exception { SecUser user = secUser(); LocationDto location = locationDto(); when(locationService.getByCoordinatesAndUserId(50.45, 30.52, user.getId())) .thenReturn(location); - when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of()); - when(weatherApiService.getDailyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of()); - mockMvc.perform(get("/forecast") - .with(user(user)) - .param("lat", "50.45") - .param("lon", "30.52")) - .andExpect(model().attribute("locationName", "Kyiv")) - .andExpect(model().attribute("login", "John Doe")); + ObjectMapper failingObjectMapper = mock(ObjectMapper.class); + when(failingObjectMapper.writeValueAsString(any())) + .thenThrow(new JsonProcessingException("Serialization failed") {}); + + ReflectionTestUtils.setField(forecastController, "objectMapper", failingObjectMapper); + + try { + mockMvc.perform(get("/forecast") + .with(user(user)) + .param("lat", "50.45") + .param("lon", "30.52")) + .andExpect(status().isOk()) + .andExpect(view().name("forecast")) + .andExpect(model().attribute("conditionLabelsJson", "{}")); + } finally { + // Restore the real ObjectMapper for any other tests + ReflectionTestUtils.setField(forecastController, "objectMapper", objectMapper); + } } @Test - void getForecast_invalidLatitude_returns400() throws Exception { + void getForecast_invalidLatitude_returns3xxRedirection() throws Exception { SecUser user = secUser(); mockMvc.perform(get("/forecast") @@ -146,7 +169,7 @@ void getForecast_invalidLatitude_returns400() throws Exception { } @Test - void getForecast_invalidLongitude_returns400() throws Exception { + void getForecast_invalidLongitude_returns3xxRedirection() throws Exception { SecUser user = secUser(); mockMvc.perform(get("/forecast") @@ -156,24 +179,4 @@ void getForecast_invalidLongitude_returns400() throws Exception { .andExpect(status().is3xxRedirection()); } - @Test - void getForecast_addsHourlyAndDailyForecast() throws Exception { - SecUser user = secUser(); - WeatherDto weather = weatherDto(); - - when(locationService.getByCoordinatesAndUserId(50.45, 30.52, user.getId())) - .thenReturn(locationDto()); - when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of(weather)); - when(weatherApiService.getDailyForecastByCoordinates(50.45, 30.52)) - .thenReturn(List.of(weather)); - - mockMvc.perform(get("/forecast") - .with(user(user)) - .param("lat", "50.45") - .param("lon", "30.52")) - .andExpect(model().attribute("hourlyForecast", List.of(weather))) - .andExpect(model().attribute("dailyForecast", List.of(weather))); - } - } diff --git a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java b/src/test/java/com/weatherviewer/controller/HomeControllerIT.java similarity index 98% rename from src/test/java/com/weatherviewer/controller/HomeControllerTest.java rename to src/test/java/com/weatherviewer/controller/HomeControllerIT.java index ec5458a..8c3909b 100644 --- a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/HomeControllerIT.java @@ -8,10 +8,12 @@ import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -34,7 +36,8 @@ @SpringBootTest @AutoConfigureMockMvc -class HomeControllerTest { +@Import(TestcontainersConfiguration.class) +class HomeControllerIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/controller/MapControllerTest.java b/src/test/java/com/weatherviewer/controller/MapControllerTest.java new file mode 100644 index 0000000..8357952 --- /dev/null +++ b/src/test/java/com/weatherviewer/controller/MapControllerTest.java @@ -0,0 +1,141 @@ +package com.weatherviewer.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.weatherviewer.dto.LocationDto; +import com.weatherviewer.model.enums.UnitSystem; +import com.weatherviewer.security.SecUser; +import com.weatherviewer.service.LocationService; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.ui.ConcurrentModel; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(MapController.class) +class MapControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + LocationService locationService; + + @MockitoSpyBean + ObjectMapper objectMapper; + + private SecUser secUser() { + return new SecUser( + UUID.randomUUID(), + "john@example.com", + "hashed", + Set.of(), + true, + "John Doe", + UnitSystem.METRIC, + null + ); + } + + private LocationDto locationDto() { + return new LocationDto() + .setId(UUID.randomUUID()) + .setName("Kyiv") + .setLatitude(50.45) + .setLongitude(30.52) + .setUserId(UUID.randomUUID()) + .setFavorite(false) + .setCreatedAt(LocalDateTime.now()); + } + + @Test + void map_returns200AndViewWithLocations() throws Exception { + SecUser user = secUser(); + when(locationService.getByUserId(user.getId())).thenReturn(List.of(locationDto())); + + mockMvc.perform(get("/map").with(user(user))) + .andExpect(status().isOk()) + .andExpect(view().name("map")) + .andExpect(model().attributeExists("locations")) + .andExpect(model().attribute("login", "John Doe")); + } + + @Test + void map_noSavedLocations_stillReturns200WithEmptyList() throws Exception { + SecUser user = secUser(); + when(locationService.getByUserId(user.getId())).thenReturn(List.of()); + + mockMvc.perform(get("/map").with(user(user))) + .andExpect(status().isOk()) + .andExpect(view().name("map")) + .andExpect(model().attribute("locations", List.of())); + } + + @Test + void map_savedLocation_mapDataJsonIncludesLocationAndLabels() throws Exception { + SecUser user = secUser(); + when(locationService.getByUserId(user.getId())).thenReturn(List.of(locationDto())); + + MvcResult result = mockMvc.perform(get("/map").with(user(user))) + .andExpect(status().isOk()) + .andReturn(); + + String mapDataJson = (String) Objects.requireNonNull(result.getModelAndView()).getModel().get("mapDataJson"); + assertThat(mapDataJson).contains("\"name\":\"Kyiv\""); + assertThat(mapDataJson).contains("\"forecast\""); + assertThat(mapDataJson).contains("\"addLocation\""); + assertThat(mapDataJson).contains("\"locationNamePlaceholder\""); + assertThat(mapDataJson).contains("\"addLocationUrl\":\"/search/add\""); + } + + @Test + void map_jsonSerializationFails_fallsBackToEmptyMapDataJson() throws Exception { + SecUser user = secUser(); + when(locationService.getByUserId(user.getId())).thenReturn(List.of(locationDto())); + doThrow(new JsonProcessingException("boom") { + }).when(objectMapper).writeValueAsString(any()); + + mockMvc.perform(get("/map").with(user(user))) + .andExpect(status().isOk()) + .andExpect(view().name("map")) + .andExpect(model().attribute("mapDataJson", "{\"locations\":[],\"layerLabels\":{}}")); + } + + @Test + void map_whenCsrfTokenPresent_includesCsrfInMapDataJson() throws Exception { + SecUser user = secUser(); + when(locationService.getByUserId(user.getId())).thenReturn(List.of(locationDto())); + + MvcResult result = mockMvc.perform(get("/map") + .with(user(user)) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn(); + + String mapDataJson = (String) Objects.requireNonNull(result.getModelAndView()).getModel().get("mapDataJson"); + assertThat(mapDataJson).contains("\"csrfParam\":\"_csrf\""); + assertThat(mapDataJson).contains("\"csrfToken\":"); + } + +} diff --git a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java b/src/test/java/com/weatherviewer/controller/ProfileControllerIT.java similarity index 96% rename from src/test/java/com/weatherviewer/controller/ProfileControllerTest.java rename to src/test/java/com/weatherviewer/controller/ProfileControllerIT.java index 1c10e96..8168279 100644 --- a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ProfileControllerIT.java @@ -6,6 +6,7 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.UserService; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import com.weatherviewer.validation.validator.PasswordMatchesValidator; import com.weatherviewer.validation.validator.UniqueEmailValidator; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -30,7 +32,8 @@ @SpringBootTest @AutoConfigureMockMvc -class ProfileControllerTest { +@Import(TestcontainersConfiguration.class) +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 96% rename from src/test/java/com/weatherviewer/controller/SearchControllerTest.java rename to src/test/java/com/weatherviewer/controller/SearchControllerIT.java index 332655a..e1f044f 100644 --- a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/SearchControllerIT.java @@ -7,6 +7,7 @@ import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import com.weatherviewer.validation.validator.PasswordMatchesValidator; import com.weatherviewer.validation.validator.UniqueEmailValidator; import com.weatherviewer.validation.validator.UniqueLocationValidator; @@ -15,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -32,7 +34,8 @@ @SpringBootTest @AutoConfigureMockMvc -class SearchControllerTest { +@Import(TestcontainersConfiguration.class) +class SearchControllerIT { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/weatherviewer/dto/ws/DashboardLocationWeatherTest.java b/src/test/java/com/weatherviewer/dto/ws/DashboardLocationWeatherTest.java new file mode 100644 index 0000000..20cf1f4 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ws/DashboardLocationWeatherTest.java @@ -0,0 +1,49 @@ +package com.weatherviewer.dto.ws; + +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.enums.TimeOfDay; +import com.weatherviewer.dto.enums.WeatherCondition; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DashboardLocationWeatherTest { + + private WeatherDto weatherDto() { + return new WeatherDto() + .setWeatherCondition(WeatherCondition.CLEAR) + .setTimeOfDay(TimeOfDay.DAY) + .setDescription("Clear sky") + .setTemperature(25.0) + .setTemperatureFeelsLike(24.0) + .setDate(new Date()); + } + + @Test + void chainedSetters_returnSameInstanceAndPersistValues() { + UUID locationId = UUID.randomUUID(); + WeatherDto weather = weatherDto(); + + DashboardLocationWeather result = new DashboardLocationWeather() + .setLocationId(locationId) + .setLocationName("Kyiv") + .setWeather(weather); + + assertThat(result.getLocationId()).isEqualTo(locationId); + assertThat(result.getLocationName()).isEqualTo("Kyiv"); + assertThat(result.getWeather()).isEqualTo(weather); + } + + @Test + void newInstance_hasNullFields() { + DashboardLocationWeather result = new DashboardLocationWeather(); + + assertThat(result.getLocationId()).isNull(); + assertThat(result.getLocationName()).isNull(); + assertThat(result.getWeather()).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ws/DashboardSubscribeRequestTest.java b/src/test/java/com/weatherviewer/dto/ws/DashboardSubscribeRequestTest.java new file mode 100644 index 0000000..d2690d9 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ws/DashboardSubscribeRequestTest.java @@ -0,0 +1,27 @@ +package com.weatherviewer.dto.ws; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DashboardSubscribeRequestTest { + + @Test + void gettersAndSetters_roundTripValues() { + DashboardSubscribeRequest request = new DashboardSubscribeRequest(); + request.setSort("nameAsc"); + request.setPage(2); + + assertThat(request.getSort()).isEqualTo("nameAsc"); + assertThat(request.getPage()).isEqualTo(2); + } + + @Test + void newInstance_hasNullSortAndPage() { + DashboardSubscribeRequest request = new DashboardSubscribeRequest(); + + assertThat(request.getSort()).isNull(); + assertThat(request.getPage()).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ws/DashboardUpdateMessageTest.java b/src/test/java/com/weatherviewer/dto/ws/DashboardUpdateMessageTest.java new file mode 100644 index 0000000..f7d2529 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ws/DashboardUpdateMessageTest.java @@ -0,0 +1,31 @@ +package com.weatherviewer.dto.ws; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DashboardUpdateMessageTest { + + @Test + void chainedSetters_returnSameInstanceAndPersistValues() { + DashboardLocationWeather locationWeather = new DashboardLocationWeather().setLocationName("Kyiv"); + + DashboardUpdateMessage message = new DashboardUpdateMessage() + .setLocations(List.of(locationWeather)) + .setUnavailableLocationNames(List.of("Lviv")); + + assertThat(message.getLocations()).containsExactly(locationWeather); + assertThat(message.getUnavailableLocationNames()).containsExactly("Lviv"); + } + + @Test + void newInstance_hasNullLists() { + DashboardUpdateMessage message = new DashboardUpdateMessage(); + + assertThat(message.getLocations()).isNull(); + assertThat(message.getUnavailableLocationNames()).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ws/ForecastSubscribeRequestTest.java b/src/test/java/com/weatherviewer/dto/ws/ForecastSubscribeRequestTest.java new file mode 100644 index 0000000..833fa5c --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ws/ForecastSubscribeRequestTest.java @@ -0,0 +1,27 @@ +package com.weatherviewer.dto.ws; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ForecastSubscribeRequestTest { + + @Test + void gettersAndSetters_roundTripValues() { + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLat(50.45); + request.setLon(30.52); + + assertThat(request.getLat()).isEqualTo(50.45); + assertThat(request.getLon()).isEqualTo(30.52); + } + + @Test + void newInstance_hasNullCoordinates() { + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + + assertThat(request.getLat()).isNull(); + assertThat(request.getLon()).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ws/ForecastUpdateMessageTest.java b/src/test/java/com/weatherviewer/dto/ws/ForecastUpdateMessageTest.java new file mode 100644 index 0000000..361e5e6 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ws/ForecastUpdateMessageTest.java @@ -0,0 +1,46 @@ +package com.weatherviewer.dto.ws; + +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.enums.TimeOfDay; +import com.weatherviewer.dto.enums.WeatherCondition; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ForecastUpdateMessageTest { + + private WeatherDto weatherDto() { + return new WeatherDto() + .setWeatherCondition(WeatherCondition.CLEAR) + .setTimeOfDay(TimeOfDay.DAY) + .setDescription("Clear sky") + .setTemperature(25.0) + .setTemperatureFeelsLike(24.0) + .setDate(new Date()); + } + + @Test + void chainedSetters_returnSameInstanceAndPersistValues() { + WeatherDto hourly = weatherDto(); + WeatherDto daily = weatherDto(); + + ForecastUpdateMessage message = new ForecastUpdateMessage() + .setHourlyForecast(List.of(hourly)) + .setDailyForecast(List.of(daily)); + + assertThat(message.getHourlyForecast()).containsExactly(hourly); + assertThat(message.getDailyForecast()).containsExactly(daily); + } + + @Test + void newInstance_hasNullLists() { + ForecastUpdateMessage message = new ForecastUpdateMessage(); + + assertThat(message.getHourlyForecast()).isNull(); + assertThat(message.getDailyForecast()).isNull(); + } + +} diff --git a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ForecastIT.java similarity index 95% rename from src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/ForecastIT.java index fc64bde..26cef06 100644 --- a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ForecastIT.java @@ -7,12 +7,14 @@ import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.security.SecUser; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; @@ -25,7 +27,8 @@ @SpringBootTest @AutoConfigureMockMvc -class ForecastIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 97% rename from src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/LocationIT.java index efa8110..4259fd3 100644 --- a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/LocationIT.java @@ -8,12 +8,14 @@ import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.security.SecUser; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; @@ -29,7 +31,8 @@ @SpringBootTest @AutoConfigureMockMvc -class LocationIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 89% rename from src/test/java/com/weatherviewer/integration/OpenApiDocumentationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/OpenApiDocumentationIT.java index 9b74df8..f0ede94 100644 --- a/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/OpenApiDocumentationIT.java @@ -1,9 +1,11 @@ package com.weatherviewer.integration; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -11,7 +13,8 @@ @SpringBootTest @AutoConfigureMockMvc -class OpenApiDocumentationIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 96% rename from src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/ProfileIT.java index c412a1d..500fe24 100644 --- a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ProfileIT.java @@ -6,12 +6,14 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.security.SecUser; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; @@ -26,7 +28,8 @@ @SpringBootTest @AutoConfigureMockMvc -class ProfileIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 97% rename from src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SearchIT.java index 64d536b..9d67770 100644 --- a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SearchIT.java @@ -10,12 +10,14 @@ import com.weatherviewer.repository.UserRepository; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -33,7 +35,8 @@ @SpringBootTest @AutoConfigureMockMvc -class SearchIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 96% rename from src/test/java/com/weatherviewer/integration/SecurityIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SecurityIT.java index 1b774e2..8e28102 100644 --- a/src/test/java/com/weatherviewer/integration/SecurityIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SecurityIT.java @@ -1,9 +1,11 @@ package com.weatherviewer.integration; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -15,7 +17,8 @@ @SpringBootTest @AutoConfigureMockMvc -class SecurityIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 95% rename from src/test/java/com/weatherviewer/integration/SignInIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/SignInIT.java index dcfb16e..df99ea4 100644 --- a/src/test/java/com/weatherviewer/integration/SignInIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SignInIT.java @@ -4,12 +4,14 @@ import com.weatherviewer.model.enums.Role; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; @@ -22,7 +24,8 @@ @SpringBootTest @AutoConfigureMockMvc -class SignInIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 96% rename from src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/UserRegistrationIT.java index 5f0eeec..c5c34fb 100644 --- a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/UserRegistrationIT.java @@ -2,11 +2,13 @@ import com.weatherviewer.model.User; import com.weatherviewer.repository.UserRepository; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; import java.util.Optional; @@ -18,7 +20,8 @@ @SpringBootTest @AutoConfigureMockMvc -class UserRegistrationIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 55% rename from src/test/java/com/weatherviewer/integration/WeatherApiCacheIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/WeatherApiCacheIT.java index 117c25d..142d5e3 100644 --- a/src/test/java/com/weatherviewer/integration/WeatherApiCacheIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/WeatherApiCacheIT.java @@ -1,21 +1,37 @@ package com.weatherviewer.integration; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.weatherviewer.service.integration.WeatherApiCache; import com.weatherviewer.service.integration.WeatherApiClient; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; 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.cache.CacheManager; +import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Objects; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; +/** + * Runs against a real Redis cache (via {@link TestcontainersConfiguration}), + * not an in-memory {@code simple} cache — so a cached value actually has to + * survive a serialize/deserialize round trip to come back on a cache hit, + * the way it would in production. That's why every cached value here is a + * real, Jackson-parsed {@link JsonNode} rather than a Mockito mock: a mock + * has nothing meaningful to serialize and would fail (or silently prove + * nothing) the moment the cache manager tries to write it to Redis. + */ @SpringBootTest -class WeatherApiCacheIntegrationTest { +@Import(TestcontainersConfiguration.class) +class WeatherApiCacheIT { + + private final ObjectMapper objectMapper = new ObjectMapper(); @MockitoBean private WeatherApiClient weatherApiClient; @@ -32,21 +48,30 @@ void clearCaches() { .forEach(name -> Objects.requireNonNull(cacheManager.getCache(name)).clear()); } + private JsonNode weatherNode(String city) throws Exception { + return objectMapper.readTree(""" + {"name":"%s","main":{"temp":21.5},"weather":[{"main":"Clear"}]} + """.formatted(city)); + } + @Test - void fetchCurrentWeatherByCity_cachedOnSecondCall() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchCurrentWeatherByCity("Kyiv")).thenReturn(mockNode); + void fetchCurrentWeatherByCity_cachedOnSecondCall() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchCurrentWeatherByCity("Kyiv")).thenReturn(realNode); - weatherApiCache.fetchCurrentWeatherByCity("Kyiv"); - weatherApiCache.fetchCurrentWeatherByCity("Kyiv"); + JsonNode first = weatherApiCache.fetchCurrentWeatherByCity("Kyiv"); + JsonNode second = weatherApiCache.fetchCurrentWeatherByCity("Kyiv"); verify(weatherApiClient, times(1)).fetchCurrentWeatherByCity("Kyiv"); + + assertThat(second).isEqualTo(realNode); + assertThat(second).isNotSameAs(first); } @Test - void fetchCurrentWeatherByCoordinates_cachedOnSecondCall() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchCurrentWeatherByCoordinates(50.45, 30.52)).thenReturn(mockNode); + void fetchCurrentWeatherByCoordinates_cachedOnSecondCall() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchCurrentWeatherByCoordinates(50.45, 30.52)).thenReturn(realNode); weatherApiCache.fetchCurrentWeatherByCoordinates(50.45, 30.52); weatherApiCache.fetchCurrentWeatherByCoordinates(50.45, 30.52); @@ -55,9 +80,9 @@ void fetchCurrentWeatherByCoordinates_cachedOnSecondCall() { } @Test - void fetchForecastByCity_cachedOnSecondCall() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchForecastByCity("Kyiv")).thenReturn(mockNode); + void fetchForecastByCity_cachedOnSecondCall() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchForecastByCity("Kyiv")).thenReturn(realNode); weatherApiCache.fetchForecastByCity("Kyiv"); weatherApiCache.fetchForecastByCity("Kyiv"); @@ -66,9 +91,9 @@ void fetchForecastByCity_cachedOnSecondCall() { } @Test - void fetchForecastByCoordinates_cachedOnSecondCall() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchForecastByCoordinates(50.45, 30.52)).thenReturn(mockNode); + void fetchForecastByCoordinates_cachedOnSecondCall() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchForecastByCoordinates(50.45, 30.52)).thenReturn(realNode); weatherApiCache.fetchForecastByCoordinates(50.45, 30.52); weatherApiCache.fetchForecastByCoordinates(50.45, 30.52); @@ -77,9 +102,9 @@ void fetchForecastByCoordinates_cachedOnSecondCall() { } @Test - void fetchGeocodingByCity_cachedOnSecondCall() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchGeocodingByCity("Kyiv")).thenReturn(mockNode); + void fetchGeocodingByCity_cachedOnSecondCall() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchGeocodingByCity("Kyiv")).thenReturn(realNode); weatherApiCache.fetchGeocodingByCity("Kyiv"); weatherApiCache.fetchGeocodingByCity("Kyiv"); @@ -88,9 +113,9 @@ void fetchGeocodingByCity_cachedOnSecondCall() { } @Test - void fetchCurrentWeatherByCity_differentCities_notCached() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchCurrentWeatherByCity(any())).thenReturn(mockNode); + void fetchCurrentWeatherByCity_differentCities_notCached() throws Exception { + when(weatherApiClient.fetchCurrentWeatherByCity(anyString())) + .thenAnswer(invocation -> weatherNode(invocation.getArgument(0))); weatherApiCache.fetchCurrentWeatherByCity("Kyiv"); weatherApiCache.fetchCurrentWeatherByCity("Lviv"); @@ -100,9 +125,9 @@ void fetchCurrentWeatherByCity_differentCities_notCached() { } @Test - void fetchCurrentWeatherByCoordinates_differentCoords_notCached() { - JsonNode mockNode = mock(JsonNode.class); - when(weatherApiClient.fetchCurrentWeatherByCoordinates(anyDouble(), anyDouble())).thenReturn(mockNode); + void fetchCurrentWeatherByCoordinates_differentCoords_notCached() throws Exception { + JsonNode realNode = weatherNode("Kyiv"); + when(weatherApiClient.fetchCurrentWeatherByCoordinates(anyDouble(), anyDouble())).thenReturn(realNode); weatherApiCache.fetchCurrentWeatherByCoordinates(50.45, 30.52); weatherApiCache.fetchCurrentWeatherByCoordinates(48.92, 24.71); diff --git a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java b/src/test/java/com/weatherviewer/integration/WeatherApiIT.java similarity index 96% rename from src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java rename to src/test/java/com/weatherviewer/integration/WeatherApiIT.java index 9745893..7efa9c9 100644 --- a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/WeatherApiIT.java @@ -5,11 +5,13 @@ import com.weatherviewer.exception.ExternalHttpCallException; import com.weatherviewer.service.WeatherApiService; import com.weatherviewer.service.integration.WeatherApiClient; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; 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.cache.CacheManager; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.ExpectedCount; @@ -27,7 +29,8 @@ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @SpringBootTest -class WeatherApiIntegrationTest { +@Import(TestcontainersConfiguration.class) +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 94% rename from src/test/java/com/weatherviewer/repository/LocationRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/LocationRepositoryIT.java index 982e9e8..1e534f3 100644 --- a/src/test/java/com/weatherviewer/repository/LocationRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/LocationRepositoryIT.java @@ -4,18 +4,23 @@ import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; 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; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -class LocationRepositoryTest { +@Import(TestcontainersConfiguration.class) +@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 89% rename from src/test/java/com/weatherviewer/repository/UserRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/UserRepositoryIT.java index ee54518..d1b8585 100644 --- a/src/test/java/com/weatherviewer/repository/UserRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/UserRepositoryIT.java @@ -4,11 +4,14 @@ import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; 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; import java.util.List; import java.util.Optional; @@ -16,7 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -class UserRepositoryTest { +@Import(TestcontainersConfiguration.class) +@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 93% rename from src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java rename to src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryIT.java index 08de6de..81c71d8 100644 --- a/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java +++ b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryIT.java @@ -5,11 +5,14 @@ import com.weatherviewer.model.enums.Role; import com.weatherviewer.model.enums.TokenType; import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.testcontainers.TestcontainersConfiguration; 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; import java.time.LocalDateTime; import java.util.Optional; @@ -17,7 +20,9 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -class VerificationTokenRepositoryTest { +@Import(TestcontainersConfiguration.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class VerificationTokenRepositoryIT { @Autowired TestEntityManager entityManager; diff --git a/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java b/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java new file mode 100644 index 0000000..5a73dd9 --- /dev/null +++ b/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java @@ -0,0 +1,72 @@ +package com.weatherviewer.rest; + +import com.weatherviewer.model.enums.UnitSystem; +import com.weatherviewer.security.SecUser; +import com.weatherviewer.service.integration.MapTileLayer; +import com.weatherviewer.service.integration.WeatherTileClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Set; +import java.util.UUID; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(MapTileController.class) +class MapTileControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + WeatherTileClient weatherTileClient; + + private SecUser secUser() { + return new SecUser( + UUID.randomUUID(), "john@example.com", "hashed", Set.of(), + true, "John Doe", UnitSystem.METRIC, null + ); + } + + @Test + void tile_knownLayer_returnsPngWithCacheHeaders() throws Exception { + byte[] fakePng = new byte[]{1, 2, 3}; + when(weatherTileClient.fetchTile(MapTileLayer.PRECIPITATION, 5, 10, 12)).thenReturn(fakePng); + + mockMvc.perform(get("/map/tiles/precipitation/5/10/12").with(user(secUser()))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_PNG)) + .andExpect(content().bytes(fakePng)) + .andExpect(header().exists("Cache-Control")); + } + + @Test + void tile_layerNameIsCaseInsensitive() throws Exception { + when(weatherTileClient.fetchTile(MapTileLayer.CLOUDS, 3, 1, 1)).thenReturn(new byte[]{9}); + + mockMvc.perform(get("/map/tiles/CLOUDS/3/1/1").with(user(secUser()))) + .andExpect(status().isOk()); + } + + @Test + void tile_unknownLayer_returns400AndNeverCallsClient() throws Exception { + mockMvc.perform(get("/map/tiles/not-a-real-layer/5/10/12").with(user(secUser()))) + .andExpect(status().isBadRequest()); + + verify(weatherTileClient, never()).fetchTile(eq(MapTileLayer.PRECIPITATION), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void tile_nonNumericCoordinate_returns400() throws Exception { + mockMvc.perform(get("/map/tiles/precipitation/abc/10/12").with(user(secUser()))) + .andExpect(status().isBadRequest()); + } + +} diff --git a/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java b/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java new file mode 100644 index 0000000..c3ed5aa --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java @@ -0,0 +1,43 @@ +package com.weatherviewer.service.integration; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class MapTileLayerTest { + + @Test + void fromRequestValue_knownLayer_resolves() { + assertThat(MapTileLayer.fromRequestValue("precipitation")).contains(MapTileLayer.PRECIPITATION); + assertThat(MapTileLayer.fromRequestValue("clouds")).contains(MapTileLayer.CLOUDS); + assertThat(MapTileLayer.fromRequestValue("temperature")).contains(MapTileLayer.TEMPERATURE); + assertThat(MapTileLayer.fromRequestValue("wind")).contains(MapTileLayer.WIND); + } + + @Test + void fromRequestValue_isCaseInsensitive() { + assertThat(MapTileLayer.fromRequestValue("PreCIPitation")).contains(MapTileLayer.PRECIPITATION); + } + + @Test + void fromRequestValue_unknownLayer_returnsEmpty() { + Optional result = MapTileLayer.fromRequestValue("radar"); + assertThat(result).isEmpty(); + } + + @Test + void fromRequestValue_rejectsOwmCodeDirectly() { + Optional result = MapTileLayer.fromRequestValue("precipitation_new"); + assertThat(result).isEmpty(); + } + + @Test + void everyLayerHasAnOwmCode() { + for (MapTileLayer layer : MapTileLayer.values()) { + assertThat(layer.getOwmCode()).isNotBlank(); + } + } + +} diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java new file mode 100644 index 0000000..9db733b --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java @@ -0,0 +1,128 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatusCode; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WeatherTileClientTest { + + private static final String SECRET_API_KEY = "test-api-key-super-secret"; + + @Mock + private RestClient restClient; + + @InjectMocks + private WeatherTileClient client; + + @Mock + private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + + @Mock + private RestClient.RequestHeadersSpec requestHeadersSpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(client, "apiKey", SECRET_API_KEY); + ReflectionTestUtils.setField(client, "tileBaseUrl", "https://tile.openweathermap.org/map"); + + lenient().doReturn(requestHeadersUriSpec).when(restClient).get(); + lenient().doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + } + + @Test + void fetchTile_returnsBytesFromClient() { + byte[] pngBytes = new byte[]{1, 2, 3, 4}; + when(responseSpec.body(byte[].class)).thenReturn(pngBytes); + + byte[] result = client.fetchTile(MapTileLayer.CLOUDS, 5, 10, 12); + + assertThat(result).isEqualTo(pngBytes); + } + + @Test + void fetchTile_buildsUrlWithOwmCodeAndAppid() { + when(responseSpec.body(byte[].class)).thenReturn(new byte[]{1}); + + client.fetchTile(MapTileLayer.PRECIPITATION, 5, 10, 12); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://tile.openweathermap.org/map/precipitation_new/5/10/12.png"); + assertThat(uri).contains("appid=" + SECRET_API_KEY); + } + + @Test + void fetchTile_apiKeyNeverAppearsInThrownExceptionMessage() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(500)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOf(ExternalHttpCallException.class) + .hasMessageNotContaining(SECRET_API_KEY); + } + + @Test + void fetchTile_upstream5xx_isRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(503)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isTrue()); + } + + @Test + void fetchTile_upstream4xx_isNotRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(404)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isFalse()); + } + + @Test + void fetchTile_upstreamStatusCodeNull_isRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(null); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isTrue()); + } + + @Test + void fetchTile_networkFailure_wrappedAsExternalHttpCallException() { + when(responseSpec.body(byte[].class)).thenThrow(new RuntimeException("connection reset")); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.TEMPERATURE, 2, 2, 2)) + .isInstanceOf(ExternalHttpCallException.class); + } + +} diff --git a/src/test/java/com/weatherviewer/testcontainers/TestcontainersConfiguration.java b/src/test/java/com/weatherviewer/testcontainers/TestcontainersConfiguration.java new file mode 100644 index 0000000..5bf43d4 --- /dev/null +++ b/src/test/java/com/weatherviewer/testcontainers/TestcontainersConfiguration.java @@ -0,0 +1,39 @@ +package com.weatherviewer.testcontainers; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Boots real Postgres and Redis containers for integration tests, instead of + * H2 (Postgres compatibility mode) and {@code spring.cache.type=simple}. + *

    + * Both containers are wired via Spring Boot's {@link ServiceConnection}, so + * no {@code spring.datasource.*} or {@code spring.data.redis.*} properties + * need to be set by hand — Spring Boot autoconfigures the datasource and + * Redis connection factory directly from the running containers. + *

    + * Import this into a test with {@code @Import(TestcontainersConfiguration.class)}. + * Spring's test context caching means all tests that import this identical + * configuration share the same pair of containers instead of starting a new per test. + */ +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")); + } + + @Bean + @ServiceConnection("redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/DashboardSubscriptionTest.java b/src/test/java/com/weatherviewer/websocket/DashboardSubscriptionTest.java new file mode 100644 index 0000000..34f7733 --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/DashboardSubscriptionTest.java @@ -0,0 +1,48 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.model.enums.UnitSystem; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DashboardSubscriptionTest { + + @Test + void accessors_returnConstructorValues() { + UUID userId = UUID.randomUUID(); + + DashboardSubscription subscription = new DashboardSubscription( + "session-1", userId, "john@example.com", UnitSystem.METRIC, "nameAsc", 2); + + assertThat(subscription.sessionId()).isEqualTo("session-1"); + assertThat(subscription.userId()).isEqualTo(userId); + assertThat(subscription.username()).isEqualTo("john@example.com"); + assertThat(subscription.units()).isEqualTo(UnitSystem.METRIC); + assertThat(subscription.sort()).isEqualTo("nameAsc"); + assertThat(subscription.page()).isEqualTo(2); + } + + @Test + void equals_sameValues_areEqual() { + UUID userId = UUID.randomUUID(); + + DashboardSubscription first = new DashboardSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + DashboardSubscription second = new DashboardSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + + assertThat(first).isEqualTo(second); + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + } + + @Test + void equals_differentSessionId_areNotEqual() { + UUID userId = UUID.randomUUID(); + + DashboardSubscription first = new DashboardSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + DashboardSubscription second = new DashboardSubscription("session-2", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + + assertThat(first).isNotEqualTo(second); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/ForecastSubscriptionTest.java b/src/test/java/com/weatherviewer/websocket/ForecastSubscriptionTest.java new file mode 100644 index 0000000..a3b933e --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/ForecastSubscriptionTest.java @@ -0,0 +1,48 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.model.enums.UnitSystem; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ForecastSubscriptionTest { + + @Test + void accessors_returnConstructorValues() { + UUID userId = UUID.randomUUID(); + + ForecastSubscription subscription = new ForecastSubscription( + "session-1", userId, "john@example.com", UnitSystem.IMPERIAL, 50.45, 30.52); + + assertThat(subscription.sessionId()).isEqualTo("session-1"); + assertThat(subscription.userId()).isEqualTo(userId); + assertThat(subscription.username()).isEqualTo("john@example.com"); + assertThat(subscription.units()).isEqualTo(UnitSystem.IMPERIAL); + assertThat(subscription.latitude()).isEqualTo(50.45); + assertThat(subscription.longitude()).isEqualTo(30.52); + } + + @Test + void equals_sameValues_areEqual() { + UUID userId = UUID.randomUUID(); + + ForecastSubscription first = new ForecastSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + ForecastSubscription second = new ForecastSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + + assertThat(first).isEqualTo(second); + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + } + + @Test + void equals_differentCoordinates_areNotEqual() { + UUID userId = UUID.randomUUID(); + + ForecastSubscription first = new ForecastSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + ForecastSubscription second = new ForecastSubscription("session-1", userId, "john@example.com", UnitSystem.METRIC, 51.0, 30.52); + + assertThat(first).isNotEqualTo(second); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/WeatherLiveUpdateSchedulerTest.java b/src/test/java/com/weatherviewer/websocket/WeatherLiveUpdateSchedulerTest.java new file mode 100644 index 0000000..817f140 --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/WeatherLiveUpdateSchedulerTest.java @@ -0,0 +1,298 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.dto.LocationDto; +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.enums.TimeOfDay; +import com.weatherviewer.dto.enums.WeatherCondition; +import com.weatherviewer.dto.ws.DashboardUpdateMessage; +import com.weatherviewer.dto.ws.ForecastUpdateMessage; +import com.weatherviewer.exception.notfound.LocationNotFoundException; +import com.weatherviewer.model.enums.UnitSystem; +import com.weatherviewer.service.LocationService; +import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.service.helper.UnitConverter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WeatherLiveUpdateSchedulerTest { + + private static final int DASHBOARD_PAGE_SIZE = 12; + private static final int MAX_CONCURRENT_FETCHES = 16; + + @Mock + private WeatherSubscriptionRegistry registry; + + @Mock + private LocationService locationService; + + @Mock + private WeatherApiService weatherApiService; + + @Mock + private UnitConverter unitConverter; + + @Mock + private SimpMessagingTemplate messagingTemplate; + + private ExecutorService weatherFetchExecutor; + private WeatherLiveUpdateScheduler scheduler; + + @BeforeEach + void setUp() { + weatherFetchExecutor = Executors.newFixedThreadPool(2); + scheduler = new WeatherLiveUpdateScheduler(registry, locationService, weatherApiService, unitConverter, messagingTemplate, weatherFetchExecutor); + ReflectionTestUtils.setField(scheduler, "dashboardPageSize", DASHBOARD_PAGE_SIZE); + ReflectionTestUtils.setField(scheduler, "maxConcurrentFetches", MAX_CONCURRENT_FETCHES); + } + + @AfterEach + void tearDown() { + weatherFetchExecutor.shutdownNow(); + } + + private LocationDto locationDto(UUID id, String name) { + return new LocationDto() + .setId(id) + .setName(name) + .setLatitude(50.45) + .setLongitude(30.52) + .setUserId(UUID.randomUUID()) + .setFavorite(false) + .setCreatedAt(LocalDateTime.now()); + } + + private WeatherDto weatherDto() { + return new WeatherDto() + .setWeatherCondition(WeatherCondition.CLEAR) + .setTimeOfDay(TimeOfDay.DAY) + .setDescription("Clear sky") + .setTemperature(25.0) + .setTemperatureFeelsLike(24.0) + .setDate(new Date()); + } + + @Test + void broadcast_noSubscriptions_sendsNothing() { + when(registry.dashboardSubscriptions()).thenReturn(List.of()); + when(registry.forecastSubscriptions()).thenReturn(List.of()); + + scheduler.broadcast(); + + verifyNoInteractions(messagingTemplate); + } + + @Test + void broadcast_dashboardSubscription_pushesConvertedWeatherForEachLocation() { + UUID userId = UUID.randomUUID(); + UUID locationId = UUID.randomUUID(); + DashboardSubscription subscription = new DashboardSubscription( + "session-1", userId, "john@example.com", UnitSystem.IMPERIAL, "nameAsc", 1); + LocationDto location = locationDto(locationId, "Kyiv"); + WeatherDto rawWeather = weatherDto(); + WeatherDto convertedWeather = weatherDto().setTemperature(77.0); + + when(registry.dashboardSubscriptions()).thenReturn(List.of(subscription)); + when(registry.forecastSubscriptions()).thenReturn(List.of()); + when(locationService.getByUserIdSorted(userId, "nameAsc", PageRequest.of(1, DASHBOARD_PAGE_SIZE))) + .thenReturn(new PageImpl<>(List.of(location))); + when(weatherApiService.getWeatherByLocation(location)).thenReturn(rawWeather); + when(unitConverter.toDisplayUnits(rawWeather, UnitSystem.IMPERIAL)).thenReturn(convertedWeather); + + scheduler.broadcast(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardUpdateMessage.class); + verify(messagingTemplate).convertAndSendToUser(eq("john@example.com"), eq("/queue/dashboard"), captor.capture()); + DashboardUpdateMessage message = captor.getValue(); + assertThat(message.getLocations()).hasSize(1); + assertThat(message.getLocations().get(0).getLocationId()).isEqualTo(locationId); + assertThat(message.getLocations().get(0).getLocationName()).isEqualTo("Kyiv"); + assertThat(message.getLocations().get(0).getWeather()).isEqualTo(convertedWeather); + assertThat(message.getUnavailableLocationNames()).isEmpty(); + } + + @Test + void broadcast_dashboardLocationWeatherFetchFails_addsToUnavailableButStillPushes() { + UUID userId = UUID.randomUUID(); + DashboardSubscription subscription = new DashboardSubscription( + "session-1", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + LocationDto location = locationDto(UUID.randomUUID(), "Kyiv"); + + when(registry.dashboardSubscriptions()).thenReturn(List.of(subscription)); + when(registry.forecastSubscriptions()).thenReturn(List.of()); + when(locationService.getByUserIdSorted(userId, "date", PageRequest.of(0, DASHBOARD_PAGE_SIZE))) + .thenReturn(new PageImpl<>(List.of(location))); + when(weatherApiService.getWeatherByLocation(location)).thenThrow(new RuntimeException("provider down")); + + scheduler.broadcast(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardUpdateMessage.class); + verify(messagingTemplate).convertAndSendToUser(eq("john@example.com"), eq("/queue/dashboard"), captor.capture()); + DashboardUpdateMessage message = captor.getValue(); + assertThat(message.getLocations()).isEmpty(); + assertThat(message.getUnavailableLocationNames()).containsExactly("Kyiv"); + } + + @Test + void broadcast_dashboardSubscriptionWithNoLocationsOnPage_sendsNothing() { + UUID userId = UUID.randomUUID(); + DashboardSubscription subscription = new DashboardSubscription( + "session-1", userId, "john@example.com", UnitSystem.METRIC, "date", 0); + + when(registry.dashboardSubscriptions()).thenReturn(List.of(subscription)); + when(registry.forecastSubscriptions()).thenReturn(List.of()); + when(locationService.getByUserIdSorted(userId, "date", PageRequest.of(0, DASHBOARD_PAGE_SIZE))) + .thenReturn(new PageImpl<>(List.of())); + + scheduler.broadcast(); + + verifyNoInteractions(messagingTemplate); + } + + @Test + void broadcast_oneDashboardSubscriptionFailsEntirely_doesNotPreventOthersFromPushing() { + UUID failingUserId = UUID.randomUUID(); + UUID healthyUserId = UUID.randomUUID(); + DashboardSubscription failingSubscription = new DashboardSubscription( + "session-1", failingUserId, "fails@example.com", UnitSystem.METRIC, "date", 0); + DashboardSubscription healthySubscription = new DashboardSubscription( + "session-2", healthyUserId, "healthy@example.com", UnitSystem.METRIC, "date", 0); + LocationDto location = locationDto(UUID.randomUUID(), "Lviv"); + WeatherDto weather = weatherDto(); + + when(registry.dashboardSubscriptions()).thenReturn(List.of(failingSubscription, healthySubscription)); + when(registry.forecastSubscriptions()).thenReturn(List.of()); + when(locationService.getByUserIdSorted(failingUserId, "date", PageRequest.of(0, DASHBOARD_PAGE_SIZE))) + .thenThrow(new RuntimeException("db unavailable")); + when(locationService.getByUserIdSorted(healthyUserId, "date", PageRequest.of(0, DASHBOARD_PAGE_SIZE))) + .thenReturn(new PageImpl<>(List.of(location))); + when(weatherApiService.getWeatherByLocation(location)).thenReturn(weather); + when(unitConverter.toDisplayUnits(weather, UnitSystem.METRIC)).thenReturn(weather); + + scheduler.broadcast(); + + verify(messagingTemplate).convertAndSendToUser(eq("healthy@example.com"), eq("/queue/dashboard"), any(DashboardUpdateMessage.class)); + verify(messagingTemplate, never()).convertAndSendToUser(eq("fails@example.com"), anyString(), any()); + } + + @Test + void broadcast_forecastSubscription_pushesHourlyAndDailyForecast() { + UUID userId = UUID.randomUUID(); + ForecastSubscription subscription = new ForecastSubscription( + "session-1", userId, "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + List hourly = List.of(weatherDto()); + List daily = List.of(weatherDto()); + + when(registry.dashboardSubscriptions()).thenReturn(List.of()); + when(registry.forecastSubscriptions()).thenReturn(List.of(subscription)); + when(locationService.getByCoordinatesAndUserId(50.45, 30.52, userId)).thenReturn(locationDto(UUID.randomUUID(), "Kyiv")); + when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)).thenReturn(hourly); + when(weatherApiService.getDailyForecastByCoordinates(50.45, 30.52)).thenReturn(daily); + when(unitConverter.toDisplayUnits(hourly, UnitSystem.METRIC)).thenReturn(hourly); + when(unitConverter.toDisplayUnits(daily, UnitSystem.METRIC)).thenReturn(daily); + + scheduler.broadcast(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ForecastUpdateMessage.class); + verify(messagingTemplate).convertAndSendToUser(eq("john@example.com"), eq("/queue/forecast"), captor.capture()); + assertThat(captor.getValue().getHourlyForecast()).isEqualTo(hourly); + assertThat(captor.getValue().getDailyForecast()).isEqualTo(daily); + } + + @Test + void broadcast_forecastLocationNoLongerOwnedByUser_skipsTickWithoutPushing() { + UUID userId = UUID.randomUUID(); + ForecastSubscription subscription = new ForecastSubscription( + "session-1", userId, "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + + when(registry.dashboardSubscriptions()).thenReturn(List.of()); + when(registry.forecastSubscriptions()).thenReturn(List.of(subscription)); + when(locationService.getByCoordinatesAndUserId(50.45, 30.52, userId)) + .thenThrow(new LocationNotFoundException("not found")); + + scheduler.broadcast(); + + verifyNoInteractions(messagingTemplate); + verifyNoInteractions(weatherApiService); + } + + @Test + void broadcast_forecastSubscriptionThrowsUnexpectedException_isIsolatedAndDoesNotPreventOtherForecastPush() { + UUID failingUserId = UUID.randomUUID(); + UUID healthyUserId = UUID.randomUUID(); + ForecastSubscription failingSubscription = new ForecastSubscription( + "session-1", failingUserId, "fails@example.com", UnitSystem.METRIC, 50.45, 30.52); + ForecastSubscription healthySubscription = new ForecastSubscription( + "session-2", healthyUserId, "healthy@example.com", UnitSystem.METRIC, 51.51, -0.13); + List forecastList = List.of(weatherDto()); + + when(registry.dashboardSubscriptions()).thenReturn(List.of()); + when(registry.forecastSubscriptions()).thenReturn(List.of(failingSubscription, healthySubscription)); + + when(locationService.getByCoordinatesAndUserId(50.45, 30.52, failingUserId)) + .thenReturn(locationDto(UUID.randomUUID(), "Kyiv")); + when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)) + .thenThrow(new RuntimeException("provider down")); + + when(locationService.getByCoordinatesAndUserId(51.51, -0.13, healthyUserId)) + .thenReturn(locationDto(UUID.randomUUID(), "London")); + when(weatherApiService.getHourlyForecastByCoordinates(51.51, -0.13)).thenReturn(forecastList); + when(weatherApiService.getDailyForecastByCoordinates(51.51, -0.13)).thenReturn(forecastList); + when(unitConverter.toDisplayUnits(forecastList, UnitSystem.METRIC)).thenReturn(forecastList); + + scheduler.broadcast(); + + verify(messagingTemplate, never()).convertAndSendToUser(eq("fails@example.com"), anyString(), any()); + verify(messagingTemplate).convertAndSendToUser(eq("healthy@example.com"), eq("/queue/forecast"), any(ForecastUpdateMessage.class)); + } + + @Test + void broadcast_dashboardAndForecastSubscriptionsTogether_pushesBoth() { + UUID dashboardUserId = UUID.randomUUID(); + UUID forecastUserId = UUID.randomUUID(); + DashboardSubscription dashboardSubscription = new DashboardSubscription( + "session-1", dashboardUserId, "dashboard@example.com", UnitSystem.METRIC, "date", 0); + ForecastSubscription forecastSubscription = new ForecastSubscription( + "session-2", forecastUserId, "forecast@example.com", UnitSystem.METRIC, 50.45, 30.52); + LocationDto dashboardLocation = locationDto(UUID.randomUUID(), "Kyiv"); + WeatherDto weather = weatherDto(); + List forecastList = List.of(weatherDto()); + + when(registry.dashboardSubscriptions()).thenReturn(List.of(dashboardSubscription)); + when(registry.forecastSubscriptions()).thenReturn(List.of(forecastSubscription)); + when(locationService.getByUserIdSorted(dashboardUserId, "date", PageRequest.of(0, DASHBOARD_PAGE_SIZE))) + .thenReturn(new PageImpl<>(List.of(dashboardLocation))); + when(weatherApiService.getWeatherByLocation(dashboardLocation)).thenReturn(weather); + when(unitConverter.toDisplayUnits(weather, UnitSystem.METRIC)).thenReturn(weather); + when(locationService.getByCoordinatesAndUserId(50.45, 30.52, forecastUserId)) + .thenReturn(locationDto(UUID.randomUUID(), "Lviv")); + when(weatherApiService.getHourlyForecastByCoordinates(50.45, 30.52)).thenReturn(forecastList); + when(weatherApiService.getDailyForecastByCoordinates(50.45, 30.52)).thenReturn(forecastList); + when(unitConverter.toDisplayUnits(forecastList, UnitSystem.METRIC)).thenReturn(forecastList); + + scheduler.broadcast(); + + verify(messagingTemplate).convertAndSendToUser(eq("dashboard@example.com"), eq("/queue/dashboard"), any(DashboardUpdateMessage.class)); + verify(messagingTemplate).convertAndSendToUser(eq("forecast@example.com"), eq("/queue/forecast"), any(ForecastUpdateMessage.class)); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/WeatherSocketControllerTest.java b/src/test/java/com/weatherviewer/websocket/WeatherSocketControllerTest.java new file mode 100644 index 0000000..1536578 --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/WeatherSocketControllerTest.java @@ -0,0 +1,238 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.dto.ws.DashboardSubscribeRequest; +import com.weatherviewer.dto.ws.ForecastSubscribeRequest; +import com.weatherviewer.model.enums.UnitSystem; +import com.weatherviewer.security.SecUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.security.Principal; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WeatherSocketControllerTest { + + @Mock + private WeatherSubscriptionRegistry registry; + + @Mock + private SimpMessageHeaderAccessor headerAccessor; + + private WeatherSocketController controller; + + @BeforeEach + void setUp() { + controller = new WeatherSocketController(registry); + } + + private SecUser secUser() { + return new SecUser( + UUID.randomUUID(), + "john@example.com", + "hashed", + Set.of(), + true, + "John Doe", + UnitSystem.METRIC, + null + ); + } + + private Authentication authenticationFor(SecUser user) { + return new TestingAuthenticationToken(user, null); + } + + @Test + void subscribeDashboard_validRequest_registersSubscription() { + SecUser user = secUser(); + Authentication principal = authenticationFor(user); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + DashboardSubscribeRequest request = new DashboardSubscribeRequest(); + request.setSort("nameAsc"); + request.setPage(2); + + controller.subscribeDashboard(request, principal, headerAccessor); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardSubscription.class); + verify(registry).registerDashboard(captor.capture()); + DashboardSubscription subscription = captor.getValue(); + assertThat(subscription.sessionId()).isEqualTo("session-1"); + assertThat(subscription.userId()).isEqualTo(user.getId()); + assertThat(subscription.username()).isEqualTo("john@example.com"); + assertThat(subscription.units()).isEqualTo(UnitSystem.METRIC); + assertThat(subscription.sort()).isEqualTo("nameAsc"); + assertThat(subscription.page()).isEqualTo(2); + } + + @Test + void subscribeDashboard_nullSort_defaultsToDate() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + controller.subscribeDashboard(new DashboardSubscribeRequest(), principal, headerAccessor); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardSubscription.class); + verify(registry).registerDashboard(captor.capture()); + assertThat(captor.getValue().sort()).isEqualTo("date"); + assertThat(captor.getValue().page()).isEqualTo(0); + } + + @Test + void subscribeDashboard_unrecognizedSort_defaultsToDate() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + DashboardSubscribeRequest request = new DashboardSubscribeRequest(); + request.setSort("not-a-real-sort"); + + controller.subscribeDashboard(request, principal, headerAccessor); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardSubscription.class); + verify(registry).registerDashboard(captor.capture()); + assertThat(captor.getValue().sort()).isEqualTo("date"); + } + + @Test + void subscribeDashboard_negativePage_defaultsToZero() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + DashboardSubscribeRequest request = new DashboardSubscribeRequest(); + request.setPage(-5); + + controller.subscribeDashboard(request, principal, headerAccessor); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DashboardSubscription.class); + verify(registry).registerDashboard(captor.capture()); + assertThat(captor.getValue().page()).isEqualTo(0); + } + + @Test + void subscribeDashboard_nullPrincipal_doesNotRegister() { + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + controller.subscribeDashboard(new DashboardSubscribeRequest(), null, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeDashboard_nonAuthenticationPrincipal_doesNotRegister() { + when(headerAccessor.getSessionId()).thenReturn("session-1"); + Principal notAnAuthentication = () -> "someone"; + + controller.subscribeDashboard(new DashboardSubscribeRequest(), notAnAuthentication, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeDashboard_authenticationWithoutSecUserPrincipal_doesNotRegister() { + when(headerAccessor.getSessionId()).thenReturn("session-1"); + Authentication principal = new TestingAuthenticationToken("not-a-secuser", null); + + controller.subscribeDashboard(new DashboardSubscribeRequest(), principal, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeDashboard_nullSessionId_doesNotRegister() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn(null); + + controller.subscribeDashboard(new DashboardSubscribeRequest(), principal, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeForecast_validRequest_registersSubscription() { + SecUser user = secUser(); + Authentication principal = authenticationFor(user); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLat(50.45); + request.setLon(30.52); + + controller.subscribeForecast(request, principal, headerAccessor); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ForecastSubscription.class); + verify(registry).registerForecast(captor.capture()); + ForecastSubscription subscription = captor.getValue(); + assertThat(subscription.sessionId()).isEqualTo("session-1"); + assertThat(subscription.userId()).isEqualTo(user.getId()); + assertThat(subscription.username()).isEqualTo("john@example.com"); + assertThat(subscription.units()).isEqualTo(UnitSystem.METRIC); + assertThat(subscription.latitude()).isEqualTo(50.45); + assertThat(subscription.longitude()).isEqualTo(30.52); + } + + @Test + void subscribeForecast_missingLatitude_doesNotRegister() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLon(30.52); + + controller.subscribeForecast(request, principal, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeForecast_missingLongitude_doesNotRegister() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLat(50.45); + + controller.subscribeForecast(request, principal, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeForecast_nullPrincipal_doesNotRegister() { + when(headerAccessor.getSessionId()).thenReturn("session-1"); + + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLat(50.45); + request.setLon(30.52); + + controller.subscribeForecast(request, null, headerAccessor); + + verifyNoInteractions(registry); + } + + @Test + void subscribeForecast_nullSessionId_doesNotRegister() { + Authentication principal = authenticationFor(secUser()); + when(headerAccessor.getSessionId()).thenReturn(null); + + ForecastSubscribeRequest request = new ForecastSubscribeRequest(); + request.setLat(50.45); + request.setLon(30.52); + + controller.subscribeForecast(request, principal, headerAccessor); + + verifyNoInteractions(registry); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/WeatherSocketEventListenerTest.java b/src/test/java/com/weatherviewer/websocket/WeatherSocketEventListenerTest.java new file mode 100644 index 0000000..76da245 --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/WeatherSocketEventListenerTest.java @@ -0,0 +1,49 @@ +package com.weatherviewer.websocket; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WeatherSocketEventListenerTest { + + @Mock + private WeatherSubscriptionRegistry registry; + + private WeatherSocketEventListener listener; + + @BeforeEach + void setUp() { + listener = new WeatherSocketEventListener(registry); + } + + private SessionDisconnectEvent disconnectEvent(String sessionId) { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + return new SessionDisconnectEvent(this, message, sessionId, CloseStatus.NORMAL); + } + + @Test + void onSessionDisconnect_removesSessionFromRegistry() { + listener.onSessionDisconnect(disconnectEvent("session-1")); + + verify(registry).remove("session-1"); + } + + @Test + void onSessionDisconnect_differentSessionIds_eachRemovedIndependently() { + listener.onSessionDisconnect(disconnectEvent("session-1")); + listener.onSessionDisconnect(disconnectEvent("session-2")); + + verify(registry).remove("session-1"); + verify(registry).remove("session-2"); + } + +} diff --git a/src/test/java/com/weatherviewer/websocket/WeatherSubscriptionRegistryTest.java b/src/test/java/com/weatherviewer/websocket/WeatherSubscriptionRegistryTest.java new file mode 100644 index 0000000..98893fd --- /dev/null +++ b/src/test/java/com/weatherviewer/websocket/WeatherSubscriptionRegistryTest.java @@ -0,0 +1,125 @@ +package com.weatherviewer.websocket; + +import com.weatherviewer.model.enums.UnitSystem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeatherSubscriptionRegistryTest { + + private WeatherSubscriptionRegistry registry; + + @BeforeEach + void setUp() { + registry = new WeatherSubscriptionRegistry(); + } + + private DashboardSubscription dashboardSubscription(String sessionId) { + return new DashboardSubscription(sessionId, UUID.randomUUID(), "john@example.com", UnitSystem.METRIC, "date", 0); + } + + private ForecastSubscription forecastSubscription(String sessionId) { + return new ForecastSubscription(sessionId, UUID.randomUUID(), "john@example.com", UnitSystem.METRIC, 50.45, 30.52); + } + + @Test + void registerDashboard_addsSubscription() { + DashboardSubscription subscription = dashboardSubscription("session-1"); + + registry.registerDashboard(subscription); + + assertThat(registry.dashboardSubscriptions()).containsExactly(subscription); + assertThat(registry.forecastSubscriptions()).isEmpty(); + } + + @Test + void registerForecast_addsSubscription() { + ForecastSubscription subscription = forecastSubscription("session-1"); + + registry.registerForecast(subscription); + + assertThat(registry.forecastSubscriptions()).containsExactly(subscription); + assertThat(registry.dashboardSubscriptions()).isEmpty(); + } + + @Test + void registerDashboard_sameSessionTwice_replacesPreviousSubscription() { + registry.registerDashboard(dashboardSubscription("session-1")); + DashboardSubscription replacement = new DashboardSubscription("session-1", UUID.randomUUID(), "john@example.com", UnitSystem.METRIC, "nameAsc", 1); + + registry.registerDashboard(replacement); + + assertThat(registry.dashboardSubscriptions()).containsExactly(replacement); + } + + @Test + void registerDashboard_sessionHadForecastSubscription_removesForecastSubscription() { + registry.registerForecast(forecastSubscription("session-1")); + + registry.registerDashboard(dashboardSubscription("session-1")); + + assertThat(registry.forecastSubscriptions()).isEmpty(); + assertThat(registry.dashboardSubscriptions()).hasSize(1); + } + + @Test + void registerForecast_sessionHadDashboardSubscription_removesDashboardSubscription() { + registry.registerDashboard(dashboardSubscription("session-1")); + + registry.registerForecast(forecastSubscription("session-1")); + + assertThat(registry.dashboardSubscriptions()).isEmpty(); + assertThat(registry.forecastSubscriptions()).hasSize(1); + } + + @Test + void remove_removesDashboardSubscription() { + registry.registerDashboard(dashboardSubscription("session-1")); + + registry.remove("session-1"); + + assertThat(registry.dashboardSubscriptions()).isEmpty(); + } + + @Test + void remove_removesForecastSubscription() { + registry.registerForecast(forecastSubscription("session-1")); + + registry.remove("session-1"); + + assertThat(registry.forecastSubscriptions()).isEmpty(); + } + + @Test + void remove_unknownSessionId_doesNothing() { + registry.registerDashboard(dashboardSubscription("session-1")); + + registry.remove("session-unknown"); + + assertThat(registry.dashboardSubscriptions()).hasSize(1); + } + + @Test + void remove_nullSessionId_doesNotThrow() { + registry.registerDashboard(dashboardSubscription("session-1")); + + registry.remove(null); + + assertThat(registry.dashboardSubscriptions()).hasSize(1); + } + + @Test + void multipleDifferentSessions_areTrackedIndependently() { + DashboardSubscription first = dashboardSubscription("session-1"); + DashboardSubscription second = dashboardSubscription("session-2"); + + registry.registerDashboard(first); + registry.registerDashboard(second); + + assertThat(registry.dashboardSubscriptions()).containsExactlyInAnyOrder(first, second); + } + +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index f175cc6..6e347d3 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,8 +1,4 @@ -spring.datasource.url=jdbc:h2:mem:weather_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=none spring.liquibase.change-log=classpath:liquibase/changelog.xml @@ -14,10 +10,9 @@ weather.api.url.suffix=/data/2.5/weather forecast.api.url.suffix=/data/2.5/forecast geo.api.url.suffix=/geo/1.0/direct weather.api.key=${WEATHER_API_KEY} +weather.tile.base.url=https://tile.openweathermap.org/map -spring.data.redis.host=localhost -spring.data.redis.port=6379 -spring.cache.type=simple +spring.cache.type=redis # --- Mail --- spring.mail.host=localhost