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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user