Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cached metadata #19

Merged
merged 2 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "weather-plus",
"version": "1.0.2",
"version": "1.0.3",
"description": "Weather Plus is a powerful wrapper around various Weather APIs that simplifies adding weather data to your application",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
Expand Down
14 changes: 11 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ describe('WeatherPlus Library', () => {
const weatherPlus = new WeatherPlus();
const response = await weatherPlus.getWeather(lat, lng);
const expectedResponse: IWeatherData = {
provider: 'nws',
dewPoint: {
value: 20,
unit: 'C',
Expand All @@ -130,6 +129,8 @@ describe('WeatherPlus Library', () => {
value: 'Sunny',
unit: 'string',
},
provider: 'nws',
cached: false,
};
expect(response).toEqual(expectedResponse);
});
Expand Down Expand Up @@ -278,8 +279,15 @@ describe('WeatherPlus Library', () => {
const response1 = await weatherPlus.getWeather(lat, lng);
const response2 = await weatherPlus.getWeather(lat, lng);

expect(response1).toEqual(response2);
// The second call should use cached data
// The second call should use cached data but otherwise be the same
expect(response1.cached).toBe(false);
expect(response2.cached).toBe(true);
expect(response1.cachedAt).toBeUndefined();
expect(response2.cachedAt).toBeDefined();
expect(response1.dewPoint).toEqual(response2.dewPoint);
expect(response1.humidity).toEqual(response2.humidity);
expect(response1.temperature).toEqual(response2.temperature);
expect(response1.conditions).toEqual(response2.conditions);
});

it('should export InvalidProviderLocationError', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ export enum IWeatherKey {
conditions = 'conditions',
}

export interface IWeatherData {
export interface IWeatherProviderWeatherData {
[IWeatherKey.dewPoint]: IDewPoint;
[IWeatherKey.humidity]: IRelativeHumidity;
[IWeatherKey.temperature]: ITemperature;
[IWeatherKey.conditions]: IConditions;
}

export interface IWeatherData extends IWeatherProviderWeatherData {
provider: string;
cached: boolean;
cachedAt?: string; // ISO-8601 formatted date string
}

export type IBaseWeatherProperty<T, U extends IWeatherUnits> = {
Expand Down
4 changes: 2 additions & 2 deletions src/providers/IWeatherProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IWeatherData } from '../interfaces';
import { IWeatherProviderWeatherData } from '../interfaces';

export interface IWeatherProvider {
name: string;
getWeather(lat: number, lng: number): Promise<IWeatherData>;
getWeather(lat: number, lng: number): Promise<IWeatherProviderWeatherData>;
}
2 changes: 0 additions & 2 deletions src/providers/nws/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ describe('NWSProvider', () => {
humidity: { value: 80, unit: 'percent' },
temperature: { value: 20, unit: 'C' },
conditions: { value: 'Clear', unit: 'string' },
provider: 'nws',
});
});

Expand Down Expand Up @@ -133,7 +132,6 @@ describe('NWSProvider', () => {
humidity: { value: 80, unit: 'percent' },
temperature: { value: 20, unit: 'C' },
conditions: { value: 'Clear', unit: 'string' },
provider: 'nws',
});
});
});
10 changes: 4 additions & 6 deletions src/providers/nws/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from 'axios';
import debug from 'debug';
import { IWeatherData, IWeatherKey, IWeatherUnits } from '../../interfaces';
import { IWeatherData, IWeatherKey, IWeatherProviderWeatherData, IWeatherUnits } from '../../interfaces';
import {
IGridpointsStations,
IPointsLatLngResponse,
Expand All @@ -18,7 +18,7 @@ export const WEATHER_KEYS = Object.values(IWeatherKey);
export class NWSProvider implements IWeatherProvider {
name = 'nws';

public async getWeather(lat: number, lng: number): Promise<IWeatherData> {
public async getWeather(lat: number, lng: number): Promise<IWeatherProviderWeatherData> {
// Check if the location is within the US
if (!isLocationInUS(lat, lng)) {
throw new InvalidProviderLocationError(
Expand All @@ -27,7 +27,7 @@ export class NWSProvider implements IWeatherProvider {
}

const data: Partial<IWeatherData> = {};
const weatherData: IWeatherData[] = [];
const weatherData: IWeatherProviderWeatherData[] = [];

try {
const observationStations = await fetchObservationStationUrl(lat, lng);
Expand Down Expand Up @@ -75,7 +75,6 @@ export class NWSProvider implements IWeatherProvider {
if (Object.keys(data).length === 0) {
throw new Error('Invalid observation data');
}
data.provider = 'nws';

return data as IWeatherData;
} catch (error) {
Expand Down Expand Up @@ -150,10 +149,9 @@ async function fetchLatestObservation(
}
}

function convertToWeatherData(observation: any): IWeatherData {
function convertToWeatherData(observation: any): IWeatherProviderWeatherData {
const properties = observation.properties;
return {
provider: 'nws',
dewPoint: {
value: properties.dewpoint.value,
unit:
Expand Down
1 change: 0 additions & 1 deletion src/providers/openweather/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ describe('OpenWeatherProvider', () => {
humidity: { value: 80, unit: 'percent' },
temperature: { value: 20, unit: 'C' },
conditions: { value: 'clear sky', unit: 'string' },
provider: 'openweather',
});
});

Expand Down
7 changes: 3 additions & 4 deletions src/providers/openweather/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from 'axios';
import debug from 'debug';
import { IWeatherData, IWeatherKey, IWeatherUnits } from '../../interfaces';
import { IWeatherUnits, IWeatherProviderWeatherData } from '../../interfaces';
import { IOpenWeatherResponse } from './interfaces';
import { IWeatherProvider } from '../IWeatherProvider';

Expand All @@ -17,7 +17,7 @@ export class OpenWeatherProvider implements IWeatherProvider {
this.apiKey = apiKey;
}

public async getWeather(lat: number, lng: number): Promise<IWeatherData> {
public async getWeather(lat: number, lng: number): Promise<IWeatherProviderWeatherData> {
const url = `https://api.openweathermap.org/data/3.0/onecall`;

const params = {
Expand All @@ -39,9 +39,8 @@ export class OpenWeatherProvider implements IWeatherProvider {
}
}

function convertToWeatherData(data: IOpenWeatherResponse): IWeatherData {
function convertToWeatherData(data: IOpenWeatherResponse): IWeatherProviderWeatherData {
return {
provider: 'openweather',
dewPoint: {
value: data.current.dew_point,
unit: IWeatherUnits.C,
Expand Down
81 changes: 76 additions & 5 deletions src/weatherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,15 @@ describe('WeatherService', () => {
temperature: { value: 15, unit: IWeatherUnits.C },
conditions: { value: 'Sunny', unit: IWeatherUnits.string },
provider: 'nws',
cached: false,
// cachedAt is undefined when data is freshly fetched
};

const weather = await weatherService.getWeather(lat, lng);

expect(weather).toEqual(expectedWeatherData);
expect(weather.cached).toBe(false);
expect(weather.cachedAt).toBeUndefined();
});

it('should fallback to the next provider if the first provider does not support the location', async () => {
Expand All @@ -114,6 +118,7 @@ describe('WeatherService', () => {
temperature: { value: 18, unit: IWeatherUnits.C },
conditions: { value: 'Cloudy', unit: IWeatherUnits.string },
provider: 'openweather',
cached: false,
};

const weather = await weatherService.getWeather(lat, lng);
Expand Down Expand Up @@ -144,12 +149,14 @@ describe('WeatherService', () => {
});

it('should use cached weather data if available', async () => {
const cachedWeatherData = {
const cachedWeatherData: IWeatherData = {
dewPoint: { value: 11, unit: IWeatherUnits.C },
humidity: { value: 75, unit: IWeatherUnits.percent },
temperature: { value: 16, unit: IWeatherUnits.C },
conditions: { value: 'Overcast', unit: IWeatherUnits.string },
provider: 'nws',
cached: true,
cachedAt: '2023-10-15T12:00:00Z',
};

const cacheGetMock = jest
Expand All @@ -167,6 +174,8 @@ describe('WeatherService', () => {
const weather = await weatherService.getWeather(lat, lng);

expect(weather).toEqual(cachedWeatherData);
expect(weather.cached).toBe(true);
expect(weather.cachedAt).toBe('2023-10-15T12:00:00Z');
expect(cacheGetMock).toHaveBeenCalled();
expect(cacheSetMock).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -252,6 +261,8 @@ describe('WeatherService', () => {
temperature: { value: 18, unit: IWeatherUnits.C },
conditions: { value: 'Partly Cloudy', unit: IWeatherUnits.string },
provider: 'openweather',
cached: false,
// cachedAt is undefined when data is freshly fetched
};

const mockProvider: IWeatherProvider = {
Expand All @@ -275,6 +286,8 @@ describe('WeatherService', () => {
expect(mockProvider.getWeather).toHaveBeenCalledWith(latitude, longitude);
expect(result).toEqual(mockWeatherData);
expect(result.provider).toBe('openweather'); // Verify provider name
expect(result.cached).toBe(false);
expect(result.cachedAt).toBeUndefined();
});

it('should bypass cache when bypassCache option is true', async () => {
Expand All @@ -283,9 +296,19 @@ describe('WeatherService', () => {
set: jest.fn(),
};

const mockWeatherData: IWeatherData = {
dewPoint: { value: 15, unit: IWeatherUnits.C },
humidity: { value: 60, unit: IWeatherUnits.percent },
temperature: { value: 25, unit: IWeatherUnits.C },
conditions: { value: 'Clear', unit: IWeatherUnits.string },
provider: 'mockProvider',
cached: false,
// cachedAt is undefined when data is freshly fetched
};

const mockProvider: IWeatherProvider = {
name: 'mockProvider',
getWeather: jest.fn().mockResolvedValue({ temperature: 25 }),
getWeather: jest.fn().mockResolvedValue(mockWeatherData),
};

const weatherService = new WeatherService({
Expand All @@ -309,10 +332,58 @@ describe('WeatherService', () => {
// Expect provider.getWeather to be called
expect(mockProvider.getWeather).toHaveBeenCalledWith(latitude, longitude);

// Expect cache.set to be called with new data
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), JSON.stringify(result));
// Expect cache.set to be called with new data including cached: true and cachedAt
expect(mockCache.set).toHaveBeenCalled();

// Capture the arguments passed to cache.set
const [cacheKey, cacheValue] = mockCache.set.mock.calls[0];

// Verify that the cache key is a string
expect(cacheKey).toEqual(expect.any(String));

// Parse the cached value
const cachedData = JSON.parse(cacheValue);

// Verify the cached data
expect(cachedData).toEqual({
...mockWeatherData,
cached: true,
cachedAt: expect.any(String),
});

// Verify the result
expect(result).toEqual({ temperature: 25 });
expect(result).toEqual(mockWeatherData);
expect(result.cached).toBe(false);
expect(result.cachedAt).toBeUndefined();
});

// Add a test to verify cachedAt when data is fetched from cache
it('should have valid cachedAt when data is retrieved from cache', async () => {
const cachedWeatherData: IWeatherData = {
dewPoint: { value: 11, unit: IWeatherUnits.C },
humidity: { value: 75, unit: IWeatherUnits.percent },
temperature: { value: 16, unit: IWeatherUnits.C },
conditions: { value: 'Overcast', unit: IWeatherUnits.string },
provider: 'nws',
cached: true,
cachedAt: '2023-10-15T12:00:00Z',
};

const cacheGetMock = jest
.fn()
.mockResolvedValue(JSON.stringify(cachedWeatherData));

// Replace cache methods with mocks
(weatherService as any).cache.get = cacheGetMock;

const lat = 38.8977;
const lng = -77.0365;

const weather = await weatherService.getWeather(lat, lng);

expect(weather).toEqual(cachedWeatherData);
expect(weather.cached).toBe(true);
expect(weather.cachedAt).toBe('2023-10-15T12:00:00Z');
expect(cacheGetMock).toHaveBeenCalled();
});
});
10 changes: 8 additions & 2 deletions src/weatherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IWeatherProvider } from './providers/IWeatherProvider';
import { ProviderFactory } from './providers/providerFactory';
import { InvalidProviderLocationError } from './errors';
import { isLocationInUS } from './utils/locationUtils';
import { IWeatherData } from './interfaces';

const log = debug('weather-plus');

Expand Down Expand Up @@ -117,10 +118,15 @@ export class WeatherService {
}

// Attempt to get weather data from the provider
const weather = await provider.getWeather(geohashLat, geohashLng);
const weather: Partial<IWeatherData> = await provider.getWeather(geohashLat, geohashLng);
weather.provider = provider.name;

// Add cached and cachedAt property to the weather data
const weatherForCache = { ...weather, cached: true, cachedAt: new Date().toISOString() };
// Store the retrieved weather data in cache
await this.cache.set(locationGeohash, JSON.stringify(weather));
await this.cache.set(locationGeohash, JSON.stringify(weatherForCache));
// In this case, we are setting cached to false because we just retrieved fresh data from the provider.
weather.cached = false;

// Return the weather data
return weather;
Expand Down
Loading