Skip to content

Commit

Permalink
Increase test coverage, bump version
Browse files Browse the repository at this point in the history
  • Loading branch information
victorquinn committed Oct 8, 2024
1 parent f53e287 commit af07779
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 50 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": "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",
Expand Down
71 changes: 67 additions & 4 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
import { Cache } from './cache';
import { RedisClientType } from 'redis';

describe('Cache', () => {
let cache: Cache;
let mockRedisClient: Partial<RedisClientType>;

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
});
});
87 changes: 86 additions & 1 deletion src/providers/nws/client.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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');
});
});
134 changes: 90 additions & 44 deletions src/providers/nws/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,76 +16,122 @@ export async function getWeather(lat: number, lng: number) {
const data: Partial<IWeatherData> = {};
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<string> {
const url = `https://api.weather.gov/points/${lat},${lng}`;
log(`URL: ${url}`);
const response = await axios.get<IPointsLatLngResponse>(url);
return response.data.properties.observationStations;

try {
const response = await axios.get<IPointsLatLngResponse>(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<IFeature[]> {
const { data: { features } } = await axios.get<IGridpointsStations>(
observationStations
);

return features;
try {
const response = await axios.get<IGridpointsStations>(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<IObservationsLatest> {
const closestStation = `${stationId}/observations/latest`;
const observationResponse = await axios.get<IObservationsLatest>(
closestStation
);
return observationResponse.data;
const url = `${stationId}/observations/latest`;

try {
const response = await axios.get<IObservationsLatest>(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 {
Expand Down

0 comments on commit af07779

Please sign in to comment.