diff --git a/pom.xml b/pom.xml index 2928ac7..ada8686 100644 --- a/pom.xml +++ b/pom.xml @@ -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 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/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; + 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/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 88b1f3f..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} diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index 7f3bdec..0bcc569 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -97,16 +97,15 @@ body { color: var(--bs-link-color) !important; } -/* The base OSM tile layer is a plain raster image with no dark variant of - its own, so it stays light regardless of data-bs-theme by default. This - inverts it into a legible dark basemap in dark mode using CSS alone - (reacts live when the theme toggles, no JS re-fetch needed). Scoped to - .base-map-tiles (set via the tileLayer's className option in - map-init.js) so it only touches the base layer -- NOT the weather - overlay tiles (precipitation/clouds/temperature/wind), which would - otherwise have their data-carrying colors inverted into nonsense, and - NOT the location markers, which live in a separate Leaflet pane and stay - their normal color in both themes for legibility. */ [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/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/home.html b/src/main/resources/templates/home.html index 659e337..b1e1afc 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -28,15 +28,22 @@

Current Weather

-
+
-
+

Location

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

Location

- weather-image
-

-

+

+

-

+

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

  • - +
  • - +
  • - +
  • - +
  • - +
  • - +
  • + + + \ No newline at end of file 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/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/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); + } + +}