diff --git a/package.json b/package.json index dadd4a7..4d3d7b5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.test.ts b/src/index.test.ts index 239343e..b594545 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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', @@ -130,6 +129,8 @@ describe('WeatherPlus Library', () => { value: 'Sunny', unit: 'string', }, + provider: 'nws', + cached: false, }; expect(response).toEqual(expectedResponse); }); @@ -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', () => { diff --git a/src/interfaces.ts b/src/interfaces.ts index 293a287..e22ac3e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -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 = { diff --git a/src/providers/IWeatherProvider.ts b/src/providers/IWeatherProvider.ts index aadc50e..9bc6b2d 100644 --- a/src/providers/IWeatherProvider.ts +++ b/src/providers/IWeatherProvider.ts @@ -1,6 +1,6 @@ -import { IWeatherData } from '../interfaces'; +import { IWeatherProviderWeatherData } from '../interfaces'; export interface IWeatherProvider { name: string; - getWeather(lat: number, lng: number): Promise; + getWeather(lat: number, lng: number): Promise; } \ No newline at end of file diff --git a/src/providers/nws/client.test.ts b/src/providers/nws/client.test.ts index 8b7305c..6e1b462 100644 --- a/src/providers/nws/client.test.ts +++ b/src/providers/nws/client.test.ts @@ -65,7 +65,6 @@ describe('NWSProvider', () => { humidity: { value: 80, unit: 'percent' }, temperature: { value: 20, unit: 'C' }, conditions: { value: 'Clear', unit: 'string' }, - provider: 'nws', }); }); @@ -133,7 +132,6 @@ describe('NWSProvider', () => { humidity: { value: 80, unit: 'percent' }, temperature: { value: 20, unit: 'C' }, conditions: { value: 'Clear', unit: 'string' }, - provider: 'nws', }); }); }); \ No newline at end of file diff --git a/src/providers/nws/client.ts b/src/providers/nws/client.ts index 788c32c..09b4110 100644 --- a/src/providers/nws/client.ts +++ b/src/providers/nws/client.ts @@ -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, @@ -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 { + public async getWeather(lat: number, lng: number): Promise { // Check if the location is within the US if (!isLocationInUS(lat, lng)) { throw new InvalidProviderLocationError( @@ -27,7 +27,7 @@ export class NWSProvider implements IWeatherProvider { } const data: Partial = {}; - const weatherData: IWeatherData[] = []; + const weatherData: IWeatherProviderWeatherData[] = []; try { const observationStations = await fetchObservationStationUrl(lat, lng); @@ -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) { @@ -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: diff --git a/src/providers/openweather/client.test.ts b/src/providers/openweather/client.test.ts index 133b95d..96b0570 100644 --- a/src/providers/openweather/client.test.ts +++ b/src/providers/openweather/client.test.ts @@ -50,7 +50,6 @@ describe('OpenWeatherProvider', () => { humidity: { value: 80, unit: 'percent' }, temperature: { value: 20, unit: 'C' }, conditions: { value: 'clear sky', unit: 'string' }, - provider: 'openweather', }); }); diff --git a/src/providers/openweather/client.ts b/src/providers/openweather/client.ts index a3eb80e..6e2ee75 100644 --- a/src/providers/openweather/client.ts +++ b/src/providers/openweather/client.ts @@ -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'; @@ -17,7 +17,7 @@ export class OpenWeatherProvider implements IWeatherProvider { this.apiKey = apiKey; } - public async getWeather(lat: number, lng: number): Promise { + public async getWeather(lat: number, lng: number): Promise { const url = `https://api.openweathermap.org/data/3.0/onecall`; const params = { @@ -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, diff --git a/src/weatherService.test.ts b/src/weatherService.test.ts index 256e1ea..df9b6b4 100644 --- a/src/weatherService.test.ts +++ b/src/weatherService.test.ts @@ -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 () => { @@ -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); @@ -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 @@ -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(); }); @@ -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 = { @@ -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 () => { @@ -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({ @@ -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(); }); }); \ No newline at end of file diff --git a/src/weatherService.ts b/src/weatherService.ts index 4d4b1a5..0e0355c 100644 --- a/src/weatherService.ts +++ b/src/weatherService.ts @@ -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'); @@ -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 = 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;