From af077796bffa022210f02d329601007839a95441 Mon Sep 17 00:00:00 2001 From: Victor Quinn Date: Tue, 8 Oct 2024 13:30:57 -0400 Subject: [PATCH] Increase test coverage, bump version --- package.json | 2 +- src/cache.test.ts | 71 +++++++++++++++- src/providers/nws/client.test.ts | 87 +++++++++++++++++++- src/providers/nws/client.ts | 134 +++++++++++++++++++++---------- 4 files changed, 244 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index a753429..d09e68b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weather-plus", - "version": "0.0.18", + "version": "0.0.19", "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/cache.test.ts b/src/cache.test.ts index 84e560b..fbf7862 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -1,25 +1,88 @@ import { Cache } from './cache'; +import { RedisClientType } from 'redis'; describe('Cache', () => { let cache: Cache; + let mockRedisClient: Partial; beforeEach(() => { - cache = new Cache(); + // Create a mock Redis client with jest-mocked methods + mockRedisClient = { + get: jest.fn(), + set: jest.fn(), + }; + + // Cast mockRedisClient to RedisClientType for the constructor + cache = new Cache(mockRedisClient as RedisClientType); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); - it('should store and retrieve data', async () => { + it('should store and retrieve data using Redis', async () => { const key = 'test-key'; const value = { data: 'test-value' }; const ttl = 3600; // e.g., 1 hour + + // Mock the Redis client's 'set' and 'get' methods + (mockRedisClient.set as jest.Mock).mockResolvedValue('OK'); + (mockRedisClient.get as jest.Mock).mockResolvedValue(JSON.stringify(value)); + await cache.set(key, JSON.stringify(value), ttl); const result = await cache.get(key); + expect(JSON.parse(result!)).toEqual(value); + expect(mockRedisClient.set).toHaveBeenCalledWith(key, JSON.stringify(value), { EX: ttl }); + expect(mockRedisClient.get).toHaveBeenCalledWith(key); }); - it('should return null for nonexistent keys', async () => { + it('should return null for nonexistent keys using Redis', async () => { + (mockRedisClient.get as jest.Mock).mockResolvedValue(undefined); + const result = await cache.get('nonexistent-key'); expect(result).toBeNull(); + expect(mockRedisClient.get).toHaveBeenCalledWith('nonexistent-key'); + }); + + // Test case to cover error handling in 'get' method + test('should throw an error when redisClient.get fails', async () => { + (mockRedisClient.get as jest.Mock).mockRejectedValue(new Error('Redis GET error')); + + await expect(cache.get('key')).rejects.toThrow('Redis GET error'); + expect(mockRedisClient.get).toHaveBeenCalledWith('key'); }); - // ... add more tests as needed ... + // Test case to cover error handling in 'set' method + test('should throw an error when redisClient.set fails', async () => { + (mockRedisClient.set as jest.Mock).mockRejectedValue(new Error('Redis SET error')); + + await expect(cache.set('key', 'value', 300)).rejects.toThrow('Redis SET error'); + expect(mockRedisClient.set).toHaveBeenCalledWith('key', 'value', { EX: 300 }); + }); + + // Additional tests for memory cache behavior + describe('Memory Cache Behavior', () => { + beforeEach(() => { + cache = new Cache(); // No Redis client provided + }); + + it('should store and retrieve data using memory cache', async () => { + const key = 'test-key'; + const value = { data: 'test-value' }; + const ttl = 3600; // e.g., 1 hour + + await cache.set(key, JSON.stringify(value), ttl); + const result = await cache.get(key); + + expect(JSON.parse(result!)).toEqual(value); + }); + + it('should return null for nonexistent keys in memory cache', async () => { + const result = await cache.get('nonexistent-key'); + expect(result).toBeNull(); + }); + + // Since memory cache doesn't throw errors, we don't need error handling tests here + }); }); \ No newline at end of file diff --git a/src/providers/nws/client.test.ts b/src/providers/nws/client.test.ts index ba1ccf2..a72c72f 100644 --- a/src/providers/nws/client.test.ts +++ b/src/providers/nws/client.test.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { fetchObservationStationUrl, fetchNearbyStations, fetchLatestObservation, convertToWeatherData } from './client'; +import { fetchObservationStationUrl, fetchNearbyStations, fetchLatestObservation, convertToWeatherData, getWeather } from './client'; describe('fetchObservationStationUrl', () => { let mock: MockAdapter; @@ -122,3 +122,88 @@ describe('convertToWeatherData', () => { expect(result).toEqual(expectedWeatherData); }); }); + +describe('getWeather', () => { + let mock: MockAdapter; + + const lat = 38.8977; + const lng = -77.0365; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + test('should return weather data successfully', async () => { + // Mock fetchObservationStationUrl + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, { + properties: { + observationStations: 'https://api.weather.gov/gridpoints/XYZ/123,456/stations', + }, + }); + + // Mock fetchNearbyStations + mock.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations').reply(200, { + features: [{ id: 'station123' }], + }); + + // Mock fetchLatestObservation + mock.onGet('station123/observations/latest').reply(200, { + properties: { + temperature: { value: 20, unitCode: 'wmoUnit:degC' }, + dewpoint: { value: 10, unitCode: 'wmoUnit:degC' }, + relativeHumidity: { value: 50, unitCode: 'wmoUnit:percent' }, + textDescription: 'Clear', + }, + }); + + const result = await getWeather(lat, lng); + + expect(result).toEqual({ + temperature: { value: 20, unit: 'C' }, + dewPoint: { value: 10, unit: 'C' }, + humidity: { value: 50, unit: 'percent' }, + conditions: { value: 'Clear', unit: 'string' }, + }); + }); + + test('should throw an error when the response is not OK', async () => { + // Mocking the first axios request to return 404 + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(404); + + await expect(getWeather(lat, lng)).rejects.toThrow('Failed to fetch observation station URL'); + }); + + test('should throw an error when axios request fails', async () => { + // Simulate network error + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).networkError(); + + await expect(getWeather(lat, lng)).rejects.toThrow('Failed to fetch observation station URL'); + }); + + test('should throw an error when response is not valid JSON', async () => { + // Mocking the first axios request with invalid JSON + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, "Invalid JSON"); + + await expect(getWeather(lat, lng)).rejects.toThrow('Failed to fetch observation station URL'); + }); + + test('should throw an error when no stations are found', async () => { + // Mock fetchObservationStationUrl + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, { + properties: { + observationStations: 'https://api.weather.gov/gridpoints/XYZ/123,456/stations', + }, + }); + + // Mock fetchNearbyStations to return no stations + mock.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations').reply(200, { + features: [], + }); + + await expect(getWeather(lat, lng)).rejects.toThrow('No stations found'); + }); +}); \ No newline at end of file diff --git a/src/providers/nws/client.ts b/src/providers/nws/client.ts index 417996e..8558f19 100644 --- a/src/providers/nws/client.ts +++ b/src/providers/nws/client.ts @@ -16,76 +16,122 @@ export async function getWeather(lat: number, lng: number) { const data: Partial = {}; const weatherData: IWeatherData[] = []; - const observationStations = await fetchObservationStationUrl(lat, lng); - const stations = await fetchNearbyStations(observationStations); + try { + const observationStations = await fetchObservationStationUrl(lat, lng); + const stations = await fetchNearbyStations(observationStations); - if (!stations.length) { - throw new Error('No stations found'); - } + if (!stations.length) { + throw new Error('No stations found'); + } - do { - try { - const stationId = stations.pop()?.id; + do { + try { + const stationId = stations.pop()?.id; - if (!stationId) { - break; - } + if (!stationId) { + break; + } - const observation = await fetchLatestObservation(stationId); - const weather = convertToWeatherData(observation); + const observation = await fetchLatestObservation(stationId); + const weather = convertToWeatherData(observation); - weatherData.push(weather); - } catch (error) { - break; + weatherData.push(weather); + } catch (error) { + log('Error fetching data from station:', error); + // Skip to the next station + } + } while ( + !WEATHER_KEYS.reduce( + (acc, key) => acc && weatherData.some((data) => data[key]), + true + ) || stations.length > 0 + ); + + for (const key of WEATHER_KEYS) { + const value = weatherData.find((data) => data[key]); + + if (value && value[key]?.value) { + data[key] = value[key] as never; + } } - } - while (!WEATHER_KEYS.reduce((acc, key) => acc && weatherData.some((data) => data[key]), true) || stations.length > 0); - - for (const key of WEATHER_KEYS) { - const value = weatherData.find((data) => data[key]); - if (value && value[key]?.value) { - data[key] = value[key] as never; - } + return data; + } catch (error) { + log('Error in getWeather:', error); + throw error; } - - return data; } -// Fetch the observation station URL from the Weather.gov API -// https://api.weather.gov/points/40.7128,-74.0060 +// Updated function with error handling export async function fetchObservationStationUrl( lat: number, lng: number ): Promise { const url = `https://api.weather.gov/points/${lat},${lng}`; log(`URL: ${url}`); - const response = await axios.get(url); - return response.data.properties.observationStations; + + try { + const response = await axios.get(url); + + // Check if response.data is an object and has the expected properties + if ( + typeof response.data === 'object' && + response.data.properties && + response.data.properties.observationStations + ) { + return response.data.properties.observationStations; + } else { + throw new Error('Invalid response data'); + } + } catch (error) { + log('Error in fetchObservationStationUrl:', error); + throw new Error('Failed to fetch observation station URL'); + } } -// Fetch the nearby stations from the Weather.gov API -// https://api.weather.gov/gridpoints/OKX/33,35/stations +// Updated function with error handling export async function fetchNearbyStations( observationStations: string ): Promise { - const { data: { features } } = await axios.get( - observationStations - ); - - return features; + try { + const response = await axios.get(observationStations); + + if ( + typeof response.data === 'object' && + response.data.features && + Array.isArray(response.data.features) + ) { + return response.data.features; + } else { + throw new Error('Invalid response data'); + } + } catch (error) { + log('Error in fetchNearbyStations:', error); + throw new Error('Failed to fetch nearby stations'); + } } -// Fetch the latest observation from the Weather.gov API -// https://api.weather.gov/stations/KNYC/observations/latest +// Updated function with error handling export async function fetchLatestObservation( stationId: string ): Promise { - const closestStation = `${stationId}/observations/latest`; - const observationResponse = await axios.get( - closestStation - ); - return observationResponse.data; + const url = `${stationId}/observations/latest`; + + try { + const response = await axios.get(url); + + if ( + typeof response.data === 'object' && + response.data.properties + ) { + return response.data; + } else { + throw new Error('Invalid observation data'); + } + } catch (error) { + log('Error in fetchLatestObservation:', error); + throw new Error('Failed to fetch latest observation'); + } } export function convertToWeatherData(observation: any): IWeatherData {