feat: add location search endpoint, update weather polling to use coordinates, and enforce weather rate limiting

- Introduce /api/location/search endpoint for location autocomplete using OpenWeather geocoding
- Refactor weather polling service and user schema to use latitude/longitude instead of location name
- Add weatherLimiter middleware to rate limit location API requests
- Update tests for new location structure and endpoint
This commit is contained in:
StarAppeal
2025-12-27 18:31:12 +01:00
parent e3926a422a
commit e678bc800b
10 changed files with 339 additions and 268 deletions
+12 -1
View File
@@ -1,13 +1,14 @@
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { authLimiter, spotifyLimiter } from "../../../src/rest/middleware/rateLimit";
import { authLimiter, spotifyLimiter, weatherLimiter } from "../../../src/rest/middleware/rateLimit";
function createTestApp() {
const app = express();
app.set("trust proxy", 1);
app.get("/auth-test", authLimiter, (_req, res) => res.status(200).send({ ok: true }));
app.get("/spotify-test", spotifyLimiter, (_req, res) => res.status(200).send({ ok: true }));
app.get("/weather-test", weatherLimiter, (_req, res) => res.status(200).send({ ok: true }));
return app;
}
@@ -41,4 +42,14 @@ describe("RateLimit", () => {
expect(res.headers["ratelimit-policy"]).toBeTruthy();
});
it("limits /weather-test after 25 requests, returns http 429", async () => {
const app = createTestApp();
await hit(app, "/weather-test", 25);
const res = await request(app).get("/weather-test");
expect(res.status).toBe(429);
expect(res.headers["ratelimit-policy"]).toBeTruthy();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import request from "supertest";
import { RestLocation } from "../../src/rest/restLocation";
import { setupTestEnvironment, type TestEnvironment } from "../helpers/testSetup";
vi.mock("../../src/services/db/UserService", () => ({
UserService: {
create: vi.fn(),
getUserByUUID: vi.fn()
}
}));
vi.mock("../../src/utils/passwordUtils", () => ({
PasswordUtils: {
validatePassword: vi.fn(),
hashPassword: vi.fn(),
comparePassword: vi.fn()
}
}));
vi.mock("../../src/services/owmApiService", () => ({
validateLocation: vi.fn()
}));
import { validateLocation } from "../../src/services/owmApiService";
describe("RestLocation", () => {
let testEnv: TestEnvironment;
const mockedValidateLocation = vi.mocked(validateLocation);
beforeEach(() => {
vi.clearAllMocks();
const restLocation = new RestLocation();
testEnv = setupTestEnvironment(restLocation.createRouter(), "/location");
});
afterEach(() => {
vi.resetAllMocks();
});
describe("GET /search", () => {
const endpoint = "/location/search";
it("should return list of locations for valid query", async () => {
const query = "Berlin";
const mockResult = [
{ name: "Berlin", country: "DE", lat: 52.5, lon: 13.4 }
];
mockedValidateLocation.mockResolvedValue(mockResult as any);
const response = await request(testEnv.app)
.get(endpoint)
.query({ q: query })
.expect(200);
expect(response.body.data.locations).toEqual(mockResult);
expect(mockedValidateLocation).toHaveBeenCalledWith(query);
});
it("should return 400 if query 'q' is missing", async () => {
const response = await request(testEnv.app)
.get(endpoint)
.expect(400);
expect(response.body.details).toContain("q is required");
});
it("should return 400 if query 'q' is empty", async () => {
const response = await request(testEnv.app)
.get(endpoint)
.query({ q: "" })
.expect(400);
expect(response.body.details).toContain("q must be a non-empty string");
});
it("should return empty list if service returns empty list (e.g. nothing found)", async () => {
mockedValidateLocation.mockResolvedValue([]);
const response = await request(testEnv.app)
.get(endpoint)
.query({ q: "GibtsNichtStadt" })
.expect(200);
expect(response.body.data.locations).toEqual([]);
});
});
});
+51 -188
View File
@@ -1,18 +1,20 @@
import {describe, it, expect, vi, beforeEach} from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import OpenWeatherAPI from "openweather-api-node";
import {getCurrentWeather} from "../../src/services/owmApiService";
import { getCurrentWeather, validateLocation } from "../../src/services/owmApiService";
vi.mock("openweather-api-node", () => {
return {
default: vi.fn().mockImplementation((ignored: any) => {
default: vi.fn().mockImplementation(() => {
return {
getCurrent: vi.fn(),
getAllLocations: vi.fn(),
setLocationByCoordinates: vi.fn(),
};
}),
};
});
const MockedOpenWeatherAPI = vi.mocked(OpenWeatherAPI, true); // true = deep mock
const MockedOpenWeatherAPI = vi.mocked(OpenWeatherAPI, true);
vi.stubGlobal("process", {
env: {
@@ -28,215 +30,76 @@ describe("owmApiService", () => {
mockWeatherInstance = {
getCurrent: vi.fn(),
} as Partial<OpenWeatherAPI> as OpenWeatherAPI;
getAllLocations: vi.fn(),
setLocationByCoordinates: vi.fn(),
};
MockedOpenWeatherAPI.mockImplementation(() => mockWeatherInstance);
MockedOpenWeatherAPI.mockImplementation(() => mockWeatherInstance as any);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("getCurrentWeather", () => {
it("should initialize OpenWeatherAPI with correct API key", async () => {
const location = "Berlin";
const mockWeatherData = {
name: "Berlin",
main: {
temp: 20.5,
feels_like: 19.8,
humidity: 65,
pressure: 1013,
},
weather: [
{
main: "Clear",
description: "clear sky",
icon: "01d",
},
],
wind: {
speed: 3.2,
deg: 180,
},
};
const lat = 52.52;
const lon = 13.40;
mockWeatherInstance.getCurrent.mockResolvedValue(mockWeatherData);
it("should initialize API and set coordinates correctly", async () => {
mockWeatherInstance.getCurrent.mockResolvedValue({ temp: 20 });
const result = await getCurrentWeather(location);
await getCurrentWeather(lat, lon);
expect(MockedOpenWeatherAPI).toHaveBeenCalledWith({
key: "test-api-key",
units: "metric"
});
expect(result).toEqual(mockWeatherData);
expect(mockWeatherInstance.setLocationByCoordinates).toHaveBeenCalledWith(lat, lon);
expect(mockWeatherInstance.getCurrent).toHaveBeenCalled();
});
it("should call getCurrent with correct parameters", async () => {
const location = "London";
const mockWeatherData = {
name: "London",
main: {temp: 15.2},
weather: [{main: "Clouds"}],
};
it("should return weather data", async () => {
const mockData = { main: { temp: 25 } };
mockWeatherInstance.getCurrent.mockResolvedValue(mockData);
mockWeatherInstance.getCurrent.mockResolvedValue(mockWeatherData);
const result = await getCurrentWeather(lat, lon);
await getCurrentWeather(location);
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledWith({
locationName: location,
units: "metric",
});
expect(result).toEqual(mockData);
});
it("should return weather data for valid location", async () => {
const location = "Tokyo";
const mockWeatherData = {
name: "Tokyo",
main: {
temp: 25.3,
feels_like: 27.1,
humidity: 78,
pressure: 1008,
},
weather: [
{
main: "Rain",
description: "light rain",
icon: "10d",
},
],
wind: {
speed: 2.1,
deg: 90,
},
clouds: {
all: 75,
},
};
it("should throw error if API call fails", async () => {
const error = new Error("API Error");
mockWeatherInstance.getCurrent.mockRejectedValue(error);
mockWeatherInstance.getCurrent.mockResolvedValue(mockWeatherData);
const result:any = await getCurrentWeather(location);
expect(result).toEqual(mockWeatherData);
expect(result.name).toBe("Tokyo");
expect(result.main.temp).toBe(25.3);
expect(result.weather[0].main).toBe("Rain");
});
it("should handle API errors", async () => {
const location = "InvalidLocation";
const apiError = new Error("City not found");
mockWeatherInstance.getCurrent.mockRejectedValue(apiError);
await expect(getCurrentWeather(location)).rejects.toThrow("City not found");
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledWith({
locationName: location,
units: "metric",
});
});
it("should handle network errors", async () => {
const location = "Paris";
const networkError = new Error("Network timeout");
mockWeatherInstance.getCurrent.mockRejectedValue(networkError);
await expect(getCurrentWeather(location)).rejects.toThrow("Network timeout");
});
it("should work with different location formats", async () => {
const locations = [
"New York",
"New York, US",
"40.7128,-74.0060", // coordinates
"10001", // zip code
];
const mockWeatherData = {name: "Test Location", main: {temp: 20}};
mockWeatherInstance.getCurrent.mockResolvedValue(mockWeatherData);
for (const location of locations) {
await getCurrentWeather(location);
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledWith({
locationName: location,
units: "metric",
});
}
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledTimes(locations.length);
});
it("should always use metric units", async () => {
const location = "Sydney";
mockWeatherInstance.getCurrent.mockResolvedValue({});
await getCurrentWeather(location);
const callArgs = mockWeatherInstance.getCurrent.mock.calls[0][0];
expect(callArgs.units).toBe("metric");
});
it("should handle empty location string", async () => {
const location = "";
const apiError = new Error("Invalid location");
mockWeatherInstance.getCurrent.mockRejectedValue(apiError);
await expect(getCurrentWeather(location)).rejects.toThrow("Invalid location");
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledWith({
locationName: "",
units: "metric",
});
});
it("should handle special characters in location", async () => {
const location = "São Paulo";
const mockWeatherData = {
name: "São Paulo",
main: {temp: 22.5},
};
mockWeatherInstance.getCurrent.mockResolvedValue(mockWeatherData);
const result = await getCurrentWeather(location);
expect(result).toEqual(mockWeatherData);
expect(mockWeatherInstance.getCurrent).toHaveBeenCalledWith({
locationName: "São Paulo",
units: "metric",
});
await expect(getCurrentWeather(lat, lon)).rejects.toThrow("API Error");
});
});
describe("OpenWeatherAPI initialization", () => {
it("should create instance with environment API key", () => {
getCurrentWeather("test");
describe("validateLocation", () => {
const query = "Köln";
expect(MockedOpenWeatherAPI).toHaveBeenCalledWith({
key: "test-api-key",
});
it("should return locations list on success", async () => {
const mockLocations = [
{ name: "Köln", country: "DE", lat: 50.7, lon: 7.1 },
{ name: "Köln", country: "US", lat: 30.0, lon: -80.0 }
];
mockWeatherInstance.getAllLocations.mockResolvedValue(mockLocations);
const result = await validateLocation(query);
expect(mockWeatherInstance.getAllLocations).toHaveBeenCalledWith(query);
expect(result).toEqual(mockLocations);
});
it("should handle missing API key", () => {
vi.stubGlobal("process", {
env: {
OWM_API_KEY: undefined,
},
});
it("should return empty array on error (handled in catch block)", async () => {
mockWeatherInstance.getAllLocations.mockRejectedValue(new Error("Network Error"));
getCurrentWeather("test");
const result = await validateLocation(query);
expect(MockedOpenWeatherAPI).toHaveBeenCalledWith({
key: undefined,
});
vi.stubGlobal("process", {
env: {
OWM_API_KEY: "test-api-key",
},
});
expect(result).toEqual([]);
});
});
});
+46 -29
View File
@@ -25,9 +25,15 @@ describe("WeatherPollingService", () => {
let pollingService: WeatherPollingService;
const BERLIN_COORDS = { lat: 52.52, lon: 13.40 };
const LONDON_COORDS = { lat: 51.50, lon: -0.12 };
const BERLIN_KEY = "52.52,13.4";
const LONDON_KEY = "51.5,-0.12";
const mockUser: IUser = {
uuid: "user-123",
location: "Berlin",
location: BERLIN_COORDS,
} as any;
beforeEach(() => {
@@ -35,7 +41,6 @@ describe("WeatherPollingService", () => {
vi.useFakeTimers();
mockedAppEventBus = appEventBus as Mocked<typeof appEventBus>;
pollingService = new WeatherPollingService();
});
@@ -47,17 +52,17 @@ describe("WeatherPollingService", () => {
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);
pollingService.subscribeUser(mockUser.uuid, BERLIN_COORDS.lat, BERLIN_COORDS.lon);
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
expect(mockedGetCurrentWeather).toHaveBeenCalledOnce();
expect(mockedGetCurrentWeather).toHaveBeenCalledWith(BERLIN_COORDS.lat, BERLIN_COORDS.lon);
});
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");
pollingService.subscribeUser("user-1", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
pollingService.subscribeUser("user-2", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
await vi.advanceTimersByTimeAsync(0);
@@ -69,23 +74,23 @@ describe("WeatherPollingService", () => {
// @ts-ignore - access to private property for test purposes
const activePolls = (pollingService as any).activeLocationPolls;
pollingService.subscribeUser("user-1", "Berlin");
pollingService.subscribeUser("user-2", "Berlin");
pollingService.subscribeUser("user-1", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
pollingService.subscribeUser("user-2", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
await vi.advanceTimersByTimeAsync(0);
expect(activePolls.has("Berlin")).toBe(true);
expect(activePolls.has(BERLIN_KEY)).toBe(true);
pollingService.unsubscribeUser("user-1", "Berlin");
expect(activePolls.has("Berlin")).toBe(true);
pollingService.unsubscribeUser("user-1", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
expect(activePolls.has(BERLIN_KEY)).toBe(true);
// @ts-ignore - access to private property for test purposes
// @ts-ignore - access to private method spy
const stopPollingSpy = vi.spyOn(pollingService as any, "_stopPollingForLocation");
pollingService.unsubscribeUser("user-2", "Berlin");
pollingService.unsubscribeUser("user-2", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
expect(stopPollingSpy).toHaveBeenCalledWith("Berlin");
expect(activePolls.has("Berlin")).toBe(false);
expect(stopPollingSpy).toHaveBeenCalledWith(BERLIN_KEY);
expect(activePolls.has(BERLIN_KEY)).toBe(false);
});
});
@@ -94,14 +99,15 @@ describe("WeatherPollingService", () => {
const weatherData = { temp: 12, city: "London" };
mockedGetCurrentWeather.mockResolvedValue(weatherData as any);
pollingService.subscribeUser("user-london-1", "London");
pollingService.subscribeUser("user-london-2", "London");
pollingService.subscribeUser("user-london-1", LONDON_COORDS.lat, LONDON_COORDS.lon);
pollingService.subscribeUser("user-london-2", LONDON_COORDS.lat, LONDON_COORDS.lon);
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"],
subscribers: expect.arrayContaining(["user-london-1", "user-london-2"]),
});
await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
@@ -131,27 +137,38 @@ describe("WeatherPollingService", () => {
const unsubscribeSpy = vi.spyOn(pollingService, "unsubscribeUser");
const subscribeSpy = vi.spyOn(pollingService, "subscribeUser");
pollingService.subscribeUser("user-moving", "Berlin");
pollingService.subscribeUser("user-moving", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
const updatedUser = {
uuid: "user-moving",
location: LONDON_COORDS
} as IUser;
const updatedUser = { uuid: "user-moving", location: "London" } as IUser;
userUpdateListener(updatedUser);
expect(unsubscribeSpy).toHaveBeenCalledOnce();
expect(unsubscribeSpy).toHaveBeenCalledWith("user-moving", "Berlin");
expect(subscribeSpy).toHaveBeenCalledTimes(2);
expect(subscribeSpy).toHaveBeenLastCalledWith("user-moving", LONDON_COORDS.lat, LONDON_COORDS.lon);
expect(subscribeSpy).toHaveBeenCalledTimes(2); // Once for Berlin, once for London
expect(subscribeSpy).toHaveBeenCalledWith("user-moving", "London");
// @ts-ignore
const subs = (pollingService as any).locationSubscriptions;
expect(subs.get(BERLIN_KEY)?.has("user-moving")).toBeFalsy();
expect(subs.get(LONDON_KEY)?.has("user-moving")).toBe(true);
});
it("should do nothing if the user's location has not changed", () => {
const unsubscribeSpy = vi.spyOn(pollingService, "unsubscribeUser");
const subscribeSpy = vi.spyOn(pollingService, "subscribeUser");
pollingService.subscribeUser("user-staying", "Berlin");
pollingService.subscribeUser("user-staying", BERLIN_COORDS.lat, BERLIN_COORDS.lon);
const updatedUser = {
uuid: "user-staying",
location: BERLIN_COORDS,
name: "New Name"
} as IUser;
const updatedUser = { uuid: "user-staying", location: "Berlin", name: "New Name" } as IUser;
userUpdateListener(updatedUser);
expect(unsubscribeSpy).not.toHaveBeenCalled();
expect(subscribeSpy).toHaveBeenCalledTimes(1); // Nur das initiale
});
});
});
});