Skip to content

Commit

Permalink
Merge pull request #19 from TextureHQ/victor/add-cachedMetadata
Browse files Browse the repository at this point in the history
Add cached metadata
  • Loading branch information
victorquinn authored Oct 10, 2024
2 parents c541c4e + a0dc9b7 commit b5ddaf0
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 27 deletions.
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

0 comments on commit b5ddaf0

Please sign in to comment.