Skip to content

Commit f12bff4

Browse files
committed
test: raise coverage across providers
1 parent e736832 commit f12bff4

File tree

7 files changed

+274
-6
lines changed

7 files changed

+274
-6
lines changed

src/providers/nws/client.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import axios from 'axios';
22
import MockAdapter from 'axios-mock-adapter';
33
import { NWSProvider } from './client';
44
import { InvalidProviderLocationError } from '../../errors';
5+
import * as conditionModule from './condition';
6+
import * as cloudinessModule from './cloudiness';
7+
import { IObservationsLatest } from './interfaces';
58

69
describe('NWSProvider', () => {
710
const latInUS = 38.8977; // Latitude in the US (e.g., Washington D.C.)
@@ -120,6 +123,51 @@ describe('NWSProvider', () => {
120123
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
121124
});
122125

126+
it('should throw when observation station metadata is malformed', async () => {
127+
mock
128+
.onGet(`https://api.weather.gov/points/${latInUS},${lngInUS}`)
129+
.reply(200, { properties: {} });
130+
131+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Failed to fetch observation station URL');
132+
});
133+
134+
it('should throw when nearby stations payload is invalid', async () => {
135+
mockObservationStationUrl();
136+
137+
mock
138+
.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations')
139+
.reply(200, { properties: {} });
140+
141+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Failed to fetch nearby stations');
142+
});
143+
144+
it('should normalize missing text description to Unknown', async () => {
145+
mockObservationStationUrl();
146+
147+
mock
148+
.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations')
149+
.reply(200, {
150+
features: [{ id: 'station123' }],
151+
});
152+
153+
const observation = {
154+
properties: {
155+
dewpoint: { value: 5, unitCode: 'wmoUnit:degC' },
156+
relativeHumidity: { value: 50 },
157+
temperature: { value: 10, unitCode: 'wmoUnit:degC' },
158+
cloudLayers: undefined as unknown as IObservationsLatest['properties']['cloudLayers'],
159+
textDescription: undefined as unknown as string,
160+
},
161+
};
162+
163+
mock.onGet('station123/observations/latest').reply(200, observation as unknown as IObservationsLatest);
164+
165+
const weatherData = await provider.getWeather(latInUS, lngInUS);
166+
167+
expect(weatherData.conditions).toEqual({ value: 'Unknown', unit: 'string', original: undefined });
168+
expect(weatherData.cloudiness).toEqual({ value: 0, unit: 'percent' });
169+
});
170+
123171
// Add this test case
124172
it('should skip stations if fetching data from a station fails', async () => {
125173
mockObservationStationUrl();
@@ -153,4 +201,60 @@ describe('NWSProvider', () => {
153201
},
154202
});
155203
});
156-
});
204+
205+
it('should throw when a station response lacks an identifier', async () => {
206+
mockObservationStationUrl();
207+
208+
mock
209+
.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations')
210+
.reply(200, {
211+
features: [{}],
212+
});
213+
214+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
215+
});
216+
217+
it('should throw when latest observation response omits properties entirely', async () => {
218+
mockObservationStationUrl();
219+
220+
mock
221+
.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations')
222+
.reply(200, {
223+
features: [{ id: 'station123' }],
224+
});
225+
226+
mock.onGet('station123/observations/latest').reply(200, {});
227+
228+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
229+
});
230+
231+
it('should throw when converted data lacks usable metric values', async () => {
232+
const conditionSpy = jest.spyOn(conditionModule, 'standardizeCondition').mockReturnValueOnce(undefined as unknown as string);
233+
const cloudSpy = jest.spyOn(cloudinessModule, 'getCloudinessFromCloudLayers').mockReturnValueOnce(undefined as unknown as number);
234+
235+
mockObservationStationUrl();
236+
237+
mock
238+
.onGet('https://api.weather.gov/gridpoints/XYZ/123,456/stations')
239+
.reply(200, {
240+
features: [{ id: 'station123' }],
241+
});
242+
243+
const observation = {
244+
properties: {
245+
dewpoint: { value: null, unitCode: 'wmoUnit:degC' } as unknown as IObservationsLatest['properties']['dewpoint'],
246+
relativeHumidity: { value: null } as unknown as IObservationsLatest['properties']['relativeHumidity'],
247+
temperature: { value: null, unitCode: 'wmoUnit:degC' } as unknown as IObservationsLatest['properties']['temperature'],
248+
textDescription: 'Clear',
249+
cloudLayers: [],
250+
},
251+
};
252+
253+
mock.onGet('station123/observations/latest').reply(200, observation as unknown as IObservationsLatest);
254+
255+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
256+
257+
conditionSpy.mockRestore();
258+
cloudSpy.mockRestore();
259+
});
260+
});

src/providers/openweather/client.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import axios from 'axios';
22
import MockAdapter from 'axios-mock-adapter';
33
import { OpenWeatherProvider } from './client';
4+
import { ProviderOutcomeReporter, defaultOutcomeReporter, setDefaultOutcomeReporter } from '../outcomeReporter';
5+
import { ProviderCallOutcome } from '../capabilities';
46

57
describe('OpenWeatherProvider', () => {
68
const lat = 51.5074; // Example latitude (London)
@@ -90,4 +92,48 @@ describe('OpenWeatherProvider', () => {
9092

9193
await expect(provider.getWeather(lat, lng)).rejects.toThrow();
9294
});
93-
});
95+
96+
it('records retry metadata when OpenWeather responds with errors', async () => {
97+
class TestReporter implements ProviderOutcomeReporter {
98+
public events: Array<{ provider: string; outcome: ProviderCallOutcome }> = [];
99+
record(provider: string, outcome: ProviderCallOutcome): void {
100+
this.events.push({ provider, outcome });
101+
}
102+
}
103+
104+
const reporter = new TestReporter();
105+
const originalReporter = defaultOutcomeReporter;
106+
setDefaultOutcomeReporter(reporter);
107+
108+
mock
109+
.onGet('https://api.openweathermap.org/data/3.0/onecall')
110+
.reply(429, {}, { 'retry-after': '5' });
111+
112+
try {
113+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Request failed with status code 429');
114+
115+
expect(reporter.events).toHaveLength(1);
116+
expect(reporter.events[0]).toEqual({
117+
provider: 'openweather',
118+
outcome: expect.objectContaining({
119+
ok: false,
120+
code: 'UpstreamError',
121+
status: 429,
122+
retryAfterMs: 5000,
123+
}),
124+
});
125+
} finally {
126+
setDefaultOutcomeReporter(originalReporter);
127+
}
128+
});
129+
130+
it('wraps unexpected rejection values in a descriptive error', async () => {
131+
mock.restore();
132+
const axiosSpy = jest.spyOn(axios, 'get').mockRejectedValueOnce('boom');
133+
134+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Failed to fetch OpenWeather data');
135+
136+
axiosSpy.mockRestore();
137+
mock = new MockAdapter(axios);
138+
});
139+
});

src/providers/policy.more.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,59 @@ describe('policy engine more branches', () => {
5353
const res = selectProviders(reg, { current: true }, fallbackConfig);
5454
expect(res.candidates).toEqual(['nws', 'openweather']);
5555
});
56+
57+
it('priority-then-health skips providers without health snapshots', () => {
58+
const registry = {
59+
listProviders: () => ['ghost'],
60+
getHealth: () => undefined,
61+
} as unknown as ProviderRegistry;
62+
63+
const res = selectProviders(registry, { current: true }, { providerPolicy: 'priority-then-health' });
64+
65+
expect(res.candidates).toEqual([]);
66+
expect(res.skipped).toEqual([]);
67+
});
68+
69+
it('weighted policy skips providers below the minimum success threshold', () => {
70+
const registry = {
71+
listProviders: () => ['nws', 'openweather'],
72+
getHealth: (id: string) =>
73+
id === 'nws'
74+
? { circuit: 'closed', successRate: 0.1 }
75+
: { circuit: 'closed', successRate: 0.9 },
76+
} as unknown as ProviderRegistry;
77+
78+
const res = selectProviders(registry, { current: true }, {
79+
providerPolicy: 'weighted',
80+
providerWeights: { openweather: 5, nws: 1 },
81+
healthThresholds: { minSuccessRate: 0.5 },
82+
});
83+
84+
expect(res.candidates).toEqual(['openweather']);
85+
expect(res.skipped).toEqual([{ id: 'nws', reason: 'below-success-threshold' }]);
86+
});
87+
88+
it('weighted policy ignores providers without health snapshots', () => {
89+
const registry = {
90+
listProviders: () => ['ghost'],
91+
getHealth: () => undefined,
92+
} as unknown as ProviderRegistry;
93+
94+
const res = selectProviders(registry, { current: true }, { providerPolicy: 'weighted' });
95+
96+
expect(res.candidates).toEqual([]);
97+
expect(res.skipped).toEqual([]);
98+
});
99+
100+
it('weighted policy skips providers with open circuits', () => {
101+
const registry = {
102+
listProviders: () => ['nws'],
103+
getHealth: () => ({ circuit: 'open', successRate: 0.9 }),
104+
} as unknown as ProviderRegistry;
105+
106+
const res = selectProviders(registry, { current: true }, { providerPolicy: 'weighted' });
107+
108+
expect(res.candidates).toEqual([]);
109+
expect(res.skipped).toEqual([{ id: 'nws', reason: 'circuit-open' }]);
110+
});
56111
});

src/providers/policy.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,3 @@ export function selectProviders(
6262

6363
return { candidates: base, skipped: [] };
6464
}
65-
66-
function dedupe<T>(arr: T[]): T[] {
67-
return Array.from(new Set(arr));
68-
}

src/providers/providerRegistry.extra.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ProviderCapability } from './capabilities';
33

44
const CAP_BASIC: ProviderCapability = { supports: { current: true } };
55
const CAP_FULL: ProviderCapability = { supports: { current: true, hourly: true, daily: true } };
6+
const CAP_NO_CURRENT: ProviderCapability = { supports: { current: false, hourly: true, daily: true, alerts: true } };
67

78
describe('ProviderRegistry extra coverage', () => {
89
it('getCapability returns registered metadata', () => {
@@ -11,6 +12,15 @@ describe('ProviderRegistry extra coverage', () => {
1112
expect(reg.getCapability('nws')).toEqual(CAP_BASIC);
1213
});
1314

15+
it('ignores duplicate registrations for the same provider', () => {
16+
const reg = new ProviderRegistry();
17+
reg.register('nws', CAP_BASIC);
18+
reg.register('nws', CAP_FULL);
19+
20+
expect(reg.listProviders({ current: true })).toEqual(['nws']);
21+
expect(reg.getCapability('nws')).toEqual(CAP_BASIC);
22+
});
23+
1424
it('listProviders filters for combined intents', () => {
1525
const reg = new ProviderRegistry();
1626
reg.register('nws', CAP_BASIC);
@@ -22,4 +32,16 @@ describe('ProviderRegistry extra coverage', () => {
2232
const reg = new ProviderRegistry();
2333
expect(() => reg.recordOutcome('unknown', { ok: true, latencyMs: 1 })).not.toThrow();
2434
});
35+
36+
it('omits providers that do not support the requested current intent', () => {
37+
const reg = new ProviderRegistry();
38+
reg.register('unsupported', CAP_NO_CURRENT);
39+
expect(reg.listProviders({ current: true })).toEqual([]);
40+
});
41+
42+
it('returns undefined capability and health for unknown providers', () => {
43+
const reg = new ProviderRegistry();
44+
expect(reg.getCapability('ghost')).toBeUndefined();
45+
expect(reg.getHealth('ghost')).toBeUndefined();
46+
});
2547
});

src/utils/locationUtils.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { isLocationInUS } from './locationUtils';
2+
3+
describe('isLocationInUS', () => {
4+
it('returns true for a US coordinate and false for an international coordinate', () => {
5+
expect(isLocationInUS(38.8977, -77.0365)).toBe(true); // Washington, D.C.
6+
expect(isLocationInUS(51.5074, -0.1278)).toBe(false); // London
7+
});
8+
9+
it('falls back gracefully when topology lacks polygon features', async () => {
10+
jest.resetModules();
11+
jest.doMock('us-atlas/states-10m.json', () => ({
12+
type: 'Topology',
13+
objects: { states: { type: 'GeometryCollection', geometries: [{ type: 'LineString', arcs: [] }] } },
14+
arcs: [],
15+
transform: { scale: [1, 1], translate: [0, 0] },
16+
}));
17+
jest.doMock('topojson-client', () => ({
18+
feature: () => ({
19+
type: 'FeatureCollection',
20+
features: undefined,
21+
}),
22+
}));
23+
24+
await jest.isolateModulesAsync(async () => {
25+
const module = await import('./locationUtils');
26+
expect(module.isLocationInUS(0, 0)).toBe(false);
27+
});
28+
29+
jest.dontMock('topojson-client');
30+
jest.dontMock('us-atlas/states-10m.json');
31+
jest.resetModules();
32+
});
33+
});

src/weatherService.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,18 @@ describe('WeatherService', () => {
255255
}).toThrow('OpenWeather provider requires an API key.');
256256
});
257257

258+
it('throws a descriptive error when no providers can supply data', async () => {
259+
const service = new WeatherService({
260+
providers: ['nws'],
261+
});
262+
263+
Reflect.set(service as unknown as Record<string, unknown>, 'providers', []);
264+
265+
await expect(service.getWeather(38.8977, -77.0365)).rejects.toThrow(
266+
'Unable to retrieve weather data from any provider.'
267+
);
268+
});
269+
258270
it('should verify that the provider name is included in the weather data', async () => {
259271
const lat = 37.7749; // San Francisco
260272
const lng = -122.4194;

0 commit comments

Comments
 (0)