add weather polling service

This commit is contained in:
StarAppeal
2025-09-20 23:09:22 +02:00
parent d85ce74dbe
commit 6f7dc961f6
12 changed files with 358 additions and 118 deletions
@@ -0,0 +1,152 @@
import {describe, it, expect, vi, beforeEach, afterEach, Mocked} from "vitest";
import {appEventBus, USER_UPDATED_EVENT, WEATHER_STATE_UPDATED_EVENT} from "../../src/utils/eventBus";
import {WeatherPollingService} from "../../src/services/weatherPollingService";
import {IUser} from "../../src/db/models/user";
import {getCurrentWeather} from "../../src/services/owmApiService";
vi.mock("../../src/services/owmApiService");
vi.mock("../../src/utils/eventBus", () => ({
appEventBus: {
on: vi.fn(),
emit: vi.fn(),
},
WEATHER_STATE_UPDATED_EVENT: 'weather:state-updated',
USER_UPDATED_EVENT: 'user:updated',
}));
vi.mock("../../src/services/owmApiService", () => ({
getCurrentWeather: vi.fn(),
}));
describe("WeatherPollingService", () => {
let mockedAppEventBus: Mocked<typeof appEventBus>;
const mockedGetCurrentWeather = vi.mocked(getCurrentWeather);
let pollingService: WeatherPollingService;
const mockUser: IUser = {
uuid: "user-123",
location: "Berlin",
} as any;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockedAppEventBus = appEventBus as Mocked<typeof appEventBus>;
pollingService = new WeatherPollingService();
});
afterEach(() => {
vi.useRealTimers();
});
describe("Subscription Management", () => {
it("should start a new poll when the first user subscribes to a location", async () => {
mockedGetCurrentWeather.mockResolvedValue({ temp: 10 } as any);
pollingService.subscribeUser(mockUser.uuid, mockUser.location);
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
expect(mockedGetCurrentWeather).toHaveBeenCalledOnce();
});
it("should NOT start a new poll if another user subscribes to the same location", async () => {
pollingService.subscribeUser("user-1", "Berlin");
pollingService.subscribeUser("user-2", "Berlin");
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
expect(mockedGetCurrentWeather).toHaveBeenCalledTimes(1);
});
it("should stop the poll when the last user unsubscribes from a location", async () => {
pollingService.subscribeUser("user-1", "Berlin");
pollingService.subscribeUser("user-2", "Berlin");
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
pollingService.unsubscribeUser("user-1", "Berlin");
expect(vi.getTimerCount()).toBe(1);
pollingService.unsubscribeUser("user-2", "Berlin");
expect(vi.getTimerCount()).toBe(0);
});
});
describe("Polling and Event Emission", () => {
it("should periodically poll the API and emit an event for all subscribers of that location", async () => {
const weatherData = { temp: 12, city: "London" };
mockedGetCurrentWeather.mockResolvedValue(weatherData as any);
pollingService.subscribeUser("user-london-1", "London");
pollingService.subscribeUser("user-london-2", "London");
await vi.advanceTimersByTimeAsync(0);
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(1);
expect(mockedAppEventBus.emit).toHaveBeenCalledWith(WEATHER_STATE_UPDATED_EVENT, {
weatherData,
subscribers: ["user-london-1", "user-london-2"],
});
await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(3);
});
});
describe("Automatic Location Change Handling (via USER_UPDATED_EVENT)", () => {
let userUpdateListener: (user: IUser) => void;
beforeEach(() => {
const onCall = mockedAppEventBus.on.mock.calls.find(
call => call[0] === USER_UPDATED_EVENT
);
if (onCall) {
userUpdateListener = onCall[1];
}
});
it("should be listening for USER_UPDATED_EVENT", () => {
expect(userUpdateListener).toBeDefined();
expect(typeof userUpdateListener).toBe("function");
});
it("should automatically move a user's subscription when their location changes", () => {
const unsubscribeSpy = vi.spyOn(pollingService, 'unsubscribeUser');
const subscribeSpy = vi.spyOn(pollingService, 'subscribeUser');
pollingService.subscribeUser("user-moving", "Berlin");
const updatedUser = { uuid: "user-moving", location: "London" } as IUser;
userUpdateListener(updatedUser);
expect(unsubscribeSpy).toHaveBeenCalledOnce();
expect(unsubscribeSpy).toHaveBeenCalledWith("user-moving", "Berlin");
expect(subscribeSpy).toHaveBeenCalledTimes(2); // Once for Berlin, once for London
expect(subscribeSpy).toHaveBeenCalledWith("user-moving", "London");
});
it("should do nothing if the user's location has not changed", () => {
const unsubscribeSpy = vi.spyOn(pollingService, 'unsubscribeUser');
pollingService.subscribeUser("user-staying", "Berlin");
const updatedUser = { uuid: "user-staying", location: "Berlin", name: "New Name" } as IUser;
userUpdateListener(updatedUser);
expect(unsubscribeSpy).not.toHaveBeenCalled();
});
});
});
@@ -6,14 +6,12 @@ import {GetSpotifyUpdatesEvent} from "../../../../src/utils/websocket/websocketC
import {SpotifyPollingService} from "../../../../src/services/spotifyPollingService";
import {createMockSpotifyPollingService,} from "../../../helpers/testSetup";
import {StopSpotifyUpdatesEvent} from "../../../../src/utils/websocket/websocketCustomEvents/stopSpotifyUpdatesEvent";
import {
GetSingleWeatherUpdateEvent,
GetWeatherUpdatesEvent
import {GetWeatherUpdatesEvent
} from "../../../../src/utils/websocket/websocketCustomEvents/getWeatherUpdatesEvent";
import {ErrorEvent} from "../../../../src/utils/websocket/websocketCustomEvents/errorEvent";
import {UpdateUserSingleEvent} from "../../../../src/utils/websocket/websocketCustomEvents/updateUserEvent";
import {StopWeatherUpdatesEvent} from "../../../../src/utils/websocket/websocketCustomEvents/stopWeatherUpdatesEvent";
import {getCurrentWeather} from "../../../../src/services/owmApiService";
import {WeatherPollingService} from "../../../../src/services/weatherPollingService";
const createMockWebSocket = (userPayload: any = {}): ExtendedWebSocket => {
return {
@@ -33,15 +31,18 @@ vi.mock("../../../../src/services/owmApiService", () => ({
getCurrentWeather: vi.fn(),
}));
vi.mock("../../../../src/services/weatherPollingService");
describe("WebSocket Custom Event Handlers", () => {
let mockSpotifyPollingService: Mocked<SpotifyPollingService>;
const mockedGetCurrentWeather = vi.mocked(getCurrentWeather);
let mockWeatherPollingService: Mocked<WeatherPollingService>;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockSpotifyPollingService = createMockSpotifyPollingService() as any;
mockWeatherPollingService = new WeatherPollingService() as Mocked<WeatherPollingService>;
})
afterEach(() => {
@@ -106,86 +107,55 @@ describe("WebSocket Custom Event Handlers", () => {
});
describe("GetWeatherUpdatesEvent", () => {
it("should emit a single weather update immediately", async () => {
const mockWs = createMockWebSocket();
const event = new GetWeatherUpdatesEvent(mockWs);
it("should subscribe the user to the WeatherPollingService using their location", async () => {
const mockWs = createMockWebSocket({
uuid: "user-uuid-weather",
location: "London"
});
const event = new GetWeatherUpdatesEvent(mockWs, mockWeatherPollingService);
await event.handler();
expect(mockWs.emit).toHaveBeenCalledWith("GET_SINGLE_WEATHER_UPDATE");
expect(mockWeatherPollingService.subscribeUser).toHaveBeenCalledOnce();
expect(mockWeatherPollingService.subscribeUser).toHaveBeenCalledWith("user-uuid-weather", "London");
});
it("should set up an interval to emit weather updates periodically", async () => {
const mockWs = createMockWebSocket();
const event = new GetWeatherUpdatesEvent(mockWs);
it("should do nothing if the user has no location", async () => {
const mockWs = createMockWebSocket({ location: undefined });
const event = new GetWeatherUpdatesEvent(mockWs, mockWeatherPollingService);
await event.handler();
expect(mockWs.emit).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(60 * 1000);
expect(mockWs.emit).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(60 * 1000);
expect(mockWs.emit).toHaveBeenCalledTimes(3);
});
it("should not set up a new interval if one is already running", async () => {
const mockWs = createMockWebSocket();
const intervalId = setInterval(() => {}, 1000); // Simuliere einen laufenden Timer
mockWs.asyncUpdates.set("WEATHER_UPDATE", intervalId);
const event = new GetWeatherUpdatesEvent(mockWs);
await event.handler();
expect(mockWs.asyncUpdates.get("WEATHER_UPDATE")).toBe(intervalId);
expect(mockWs.emit).toHaveBeenCalledOnce();
});
});
describe("GetSingleWeatherUpdateEvent", () => {
it("should fetch weather and send an update to the client", async () => {
const mockWs = createMockWebSocket({ location: "London" });
const weatherData = { temp: 15, city: "London" };
mockedGetCurrentWeather.mockResolvedValue(weatherData as any);
const event = new GetSingleWeatherUpdateEvent(mockWs);
await event.handler();
expect(mockedGetCurrentWeather).toHaveBeenCalledOnce();
expect(mockedGetCurrentWeather).toHaveBeenCalledWith("London");
expect(mockWs.send).toHaveBeenCalledOnce();
const expectedMessage = JSON.stringify({ type: "WEATHER_UPDATE", payload: weatherData });
expect(mockWs.send).toHaveBeenCalledWith(expectedMessage, { binary: false });
expect(mockWeatherPollingService.subscribeUser).not.toHaveBeenCalled();
});
});
describe("StopWeatherUpdatesEvent", () => {
it("should clear the weather update interval if it exists", async () => {
const mockWs = createMockWebSocket();
const intervalId = setInterval(() => {}, 1000);
mockWs.asyncUpdates.set("WEATHER_UPDATE", intervalId);
it("should unsubscribe the user from the WeatherPollingService using their location", async () => {
const mockWs = createMockWebSocket({
uuid: "user-uuid-weather",
location: "Paris"
});
const event = new StopWeatherUpdatesEvent(mockWs);
const event = new StopWeatherUpdatesEvent(mockWs, mockWeatherPollingService);
await event.handler();
expect(mockWs.asyncUpdates.has("WEATHER_UPDATE")).toBe(false);
expect(vi.getTimerCount()).toBe(0);
expect(mockWeatherPollingService.unsubscribeUser).toHaveBeenCalledOnce();
expect(mockWeatherPollingService.unsubscribeUser).toHaveBeenCalledWith("user-uuid-weather", "Paris");
});
it("should do nothing if no weather update interval is running", async () => {
const mockWs = createMockWebSocket();
it("should do nothing if the user has no location", async () => {
const mockWs = createMockWebSocket({ location: undefined });
const event = new StopWeatherUpdatesEvent(mockWs);
const event = new StopWeatherUpdatesEvent(mockWs, mockWeatherPollingService);
await event.handler();
expect(mockWs.asyncUpdates.size).toBe(0);
expect(mockWeatherPollingService.unsubscribeUser).not.toHaveBeenCalled();
});
});
describe("UpdateUserSingleEvent", () => {
it("should update the user property on the websocket object", async () => {
const mockWs = createMockWebSocket();
@@ -3,11 +3,13 @@ import { WebsocketEventHandler } from "../../../src/utils/websocket/websocketEve
import { ExtendedWebSocket } from "../../../src/interfaces/extendedWebsocket";
import { CustomWebsocketEvent } from "../../../src/utils/websocket/websocketCustomEvents/customWebsocketEvent";
import {SpotifyPollingService} from "../../../src/services/spotifyPollingService";
import {WeatherPollingService} from "../../../src/services/weatherPollingService";
describe("WebsocketEventHandler", () => {
let mockWebSocket: Mocked<ExtendedWebSocket>;
let websocketEventHandler: WebsocketEventHandler;
let mockSpotifyPollingService: Mocked<SpotifyPollingService>
let mockWeatherPollingService: Mocked<WeatherPollingService>
let registeredHandlers: Map<string, (...args: any[]) => void>;
beforeEach(() => {
@@ -31,8 +33,9 @@ describe("WebsocketEventHandler", () => {
// not used in this test
mockSpotifyPollingService = {} as Mocked<SpotifyPollingService>;
mockWeatherPollingService = {} as Mocked<WeatherPollingService>;
websocketEventHandler = new WebsocketEventHandler(mockWebSocket, mockSpotifyPollingService);
websocketEventHandler = new WebsocketEventHandler(mockWebSocket, mockSpotifyPollingService, mockWeatherPollingService);
});
afterEach(() => {
+6 -2
View File
@@ -10,6 +10,7 @@ import {UserService} from "../src/services/db/UserService";
import {SpotifyPollingService} from "../src/services/spotifyPollingService";
import { USER_UPDATED_EVENT, SPOTIFY_STATE_UPDATED_EVENT } from "../src/utils/eventBus";
import { WebsocketEventType } from "../src/utils/websocket/websocketCustomEvents/websocketEventType";
import {WeatherPollingService} from "../src/services/weatherPollingService";
let mockWssInstance: Mocked<WebSocketServer>;
let mockServerEventHandler: Mocked<WebsocketServerEventHandler>;
@@ -25,6 +26,7 @@ vi.mock("../src/utils/eventBus", () => ({
},
USER_UPDATED_EVENT: 'user:updated',
SPOTIFY_STATE_UPDATED_EVENT: 'spotify:state-updated',
WEATHER_STATE_UPDATED_EVENT: 'weather-state:updated',
}));
vi.mock("ws", () => ({
@@ -44,6 +46,7 @@ describe("ExtendedWebSocketServer", () => {
let extendedWss: ExtendedWebSocketServer;
let mockUserService: Mocked<UserService>;
let mockSpotifyPollingService: Mocked<SpotifyPollingService>
let mockWeatherPollingService: Mocked<WeatherPollingService>;
beforeEach(() => {
vi.clearAllMocks();
@@ -63,10 +66,11 @@ describe("ExtendedWebSocketServer", () => {
} as unknown as Mocked<WebSocketServer>;
mockSpotifyPollingService = {} as any;
mockWeatherPollingService = {} as any;
mockUserService = createMockUserService();
extendedWss = new ExtendedWebSocketServer(mockHttpServer, mockUserService, mockSpotifyPollingService);
extendedWss = new ExtendedWebSocketServer(mockHttpServer, mockUserService, mockSpotifyPollingService, mockWeatherPollingService);
});
describe("Constructor and Setup", () => {
@@ -136,7 +140,7 @@ describe("ExtendedWebSocketServer", () => {
it("should create and configure a WebsocketEventHandler for new clients", () => {
connectionHandler(mockWsClient, {});
expect(vi.mocked(WebsocketEventHandler)).toHaveBeenCalledWith(mockWsClient, mockSpotifyPollingService);
expect(vi.mocked(WebsocketEventHandler)).toHaveBeenCalledWith(mockWsClient, mockSpotifyPollingService, mockWeatherPollingService);
expect(mockClientEventHandler.enableErrorEvent).toHaveBeenCalled();
expect(mockClientEventHandler.enablePongEvent).toHaveBeenCalled();
expect(mockClientEventHandler.enableMessageEvent).toHaveBeenCalled();