Skip to content

Commit c4c66f9

Browse files
authored
Merge pull request #26 from TextureHQ/feat/tomorrow-provider
feat: integrate Tomorrow.io provider
2 parents 1423d65 + 39aa5cf commit c4c66f9

File tree

13 files changed

+362
-7
lines changed

13 files changed

+362
-7
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Project Structure & Module Organization
44
- `src/` houses all TypeScript source; entry point is `src/index.ts`, with domain logic in `weatherService.ts` and provider implementations under `src/providers/`.
5-
- Each provider keeps its own helpers (`openweather/`, `nws/`) and colocated tests (`*.test.ts`). Shared contracts live in `interfaces.ts` and `providers/IWeatherProvider.ts`.
5+
- Each provider keeps its own helpers (`openweather/`, `nws/`, `tomorrow/`) and colocated tests (`*.test.ts`). Shared contracts live in `interfaces.ts` and `providers/IWeatherProvider.ts`.
66
- Utilities (caching, error taxonomy, normalization) sit in `src/utils/` and `src/errors.ts`. Build artifacts publish to `dist/` (CJS and ESM). Architectural notes and RFCs reside in `docs/rfcs/`.
77

88
## Build, Test, and Development Commands

src/providers/capabilities.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OPENWEATHER_CAPABILITY } from './openweather/client';
22
import { NWS_CAPABILITY } from './nws/client';
33
import { ProviderCapability } from './capabilities';
4+
import { TOMORROW_CAPABILITY } from './tomorrow/client';
45

56
describe('provider capabilities', () => {
67
it('NWS_CAPABILITY matches expected shape and values', () => {
@@ -21,10 +22,19 @@ describe('provider capabilities', () => {
2122
expect(cap.units).toEqual(expect.arrayContaining(['standard', 'metric', 'imperial']));
2223
});
2324

25+
it('TOMORROW_CAPABILITY matches expected shape and values', () => {
26+
const cap: ProviderCapability = TOMORROW_CAPABILITY;
27+
expect(cap.supports.current).toBe(true);
28+
expect(cap.supports.hourly).toBeFalsy();
29+
expect(cap.supports.daily).toBeFalsy();
30+
expect(cap.supports.alerts).toBeFalsy();
31+
});
32+
2433
it('validates the built-in capabilities map explicitly', () => {
2534
const map = {
2635
nws: NWS_CAPABILITY,
2736
openweather: OPENWEATHER_CAPABILITY,
37+
tomorrow: TOMORROW_CAPABILITY,
2838
} as const;
2939
expect(map).toEqual({
3040
nws: {
@@ -36,6 +46,10 @@ describe('provider capabilities', () => {
3646
units: ['standard', 'metric', 'imperial'],
3747
locales: [],
3848
},
49+
tomorrow: {
50+
supports: { current: true, hourly: false, daily: false, alerts: false },
51+
regions: [],
52+
},
3953
});
4054
});
4155
});

src/providers/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type ProviderId = 'nws' | 'openweather' | (string & {});
1+
export type ProviderId = 'nws' | 'openweather' | 'tomorrow' | (string & {});
22

33
export interface ProviderCapability {
44
supports: {

src/providers/capabilitiesMap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { OPENWEATHER_CAPABILITY } from './openweather/client';
22
import { NWS_CAPABILITY } from './nws/client';
3+
import { TOMORROW_CAPABILITY } from './tomorrow/client';
34
import { ProviderCapability } from './capabilities';
45

56
export function getBuiltInCapabilities(): Record<string, ProviderCapability> {
67
return {
78
nws: NWS_CAPABILITY,
89
openweather: OPENWEATHER_CAPABILITY,
10+
tomorrow: TOMORROW_CAPABILITY,
911
} as const;
1012
}

src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { IWeatherProvider } from './IWeatherProvider';
22
export { NWSProvider } from './nws/client';
33
export { OpenWeatherProvider } from './openweather/client';
4+
export { TomorrowProvider } from './tomorrow/client';
45
export * from './capabilities';
56
export { defaultOutcomeReporter, NoopProviderOutcomeReporter, ProviderOutcomeReporter } from './outcomeReporter';

src/providers/nws/client.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ describe('NWSProvider', () => {
214214
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
215215
});
216216

217+
it('reports failure when all stations return unusable data', 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+
properties: {},
228+
});
229+
230+
await expect(provider.getWeather(latInUS, lngInUS)).rejects.toThrow('Invalid observation data');
231+
});
232+
217233
it('should throw when latest observation response omits properties entirely', async () => {
218234
mockObservationStationUrl();
219235

src/providers/nws/client.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,6 @@ export class NWSProvider implements IWeatherProvider {
8282
}
8383
}
8484

85-
if (Object.keys(data).length === 0) {
86-
throw new Error('Invalid observation data');
87-
}
88-
8985
defaultOutcomeReporter.record('nws', { ok: true, latencyMs: Date.now() - start });
9086
return data;
9187
} catch (error) {
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { ProviderFactory } from './providerFactory';
22
import { ProviderNotSupportedError } from '../errors';
3+
import { TomorrowProvider } from './tomorrow/client';
34

45
describe('ProviderFactory', () => {
56
it('should throw ProviderNotSupportedError for unsupported providers', () => {
67
expect(() => {
78
ProviderFactory.createProvider('unsupportedProvider');
89
}).toThrow(ProviderNotSupportedError);
910
});
10-
});
11+
12+
it('creates a TomorrowProvider when requested', () => {
13+
const provider = ProviderFactory.createProvider('tomorrow', 'api-key');
14+
expect(provider).toBeInstanceOf(TomorrowProvider);
15+
expect(provider.name).toBe('tomorrow');
16+
});
17+
});

src/providers/providerFactory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IWeatherProvider } from './IWeatherProvider';
22
import { NWSProvider } from './nws/client';
33
import { OpenWeatherProvider } from './openweather/client';
4+
import { TomorrowProvider } from './tomorrow/client';
45
import { ProviderNotSupportedError } from '../errors';
56
import { ProviderId } from './capabilities';
67

@@ -11,6 +12,8 @@ export const ProviderFactory = {
1112
return new NWSProvider();
1213
case 'openweather':
1314
return new OpenWeatherProvider(apiKey ?? '');
15+
case 'tomorrow':
16+
return new TomorrowProvider(apiKey ?? '');
1417
default:
1518
throw new ProviderNotSupportedError(
1619
`Provider ${providerName} is not supported yet`
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import axios from 'axios';
2+
import MockAdapter from 'axios-mock-adapter';
3+
import { TomorrowProvider } from './client';
4+
import { ProviderOutcomeReporter } from '../outcomeReporter';
5+
import { defaultOutcomeReporter, setDefaultOutcomeReporter } from '../outcomeReporter';
6+
import { ProviderCallOutcome } from '../capabilities';
7+
8+
describe('TomorrowProvider', () => {
9+
const lat = 43.6535;
10+
const lng = -79.3839;
11+
const apiKey = 'test-api-key';
12+
let mock: MockAdapter;
13+
let provider: TomorrowProvider;
14+
15+
beforeEach(() => {
16+
mock = new MockAdapter(axios);
17+
provider = new TomorrowProvider(apiKey);
18+
});
19+
20+
afterEach(() => {
21+
mock.restore();
22+
});
23+
24+
it('throws if API key is missing', () => {
25+
expect(() => new TomorrowProvider('')).toThrow('Tomorrow.io provider requires an API key.');
26+
});
27+
28+
it('converts Tomorrow.io realtime response into weather data', async () => {
29+
const mockResponse = {
30+
data: {
31+
time: '2023-01-26T07:48:00Z',
32+
values: {
33+
cloudCover: 100,
34+
dewPoint: 0.88,
35+
humidity: 96,
36+
temperature: 1.88,
37+
weatherCode: 1001,
38+
},
39+
},
40+
location: {
41+
lat,
42+
lon: lng,
43+
},
44+
};
45+
46+
mock
47+
.onGet('https://api.tomorrow.io/v4/weather/realtime', {
48+
params: {
49+
location: `${lat},${lng}`,
50+
apikey: apiKey,
51+
},
52+
})
53+
.reply(200, mockResponse);
54+
55+
const data = await provider.getWeather(lat, lng);
56+
57+
expect(data).toEqual({
58+
temperature: { value: 1.88, unit: 'C' },
59+
humidity: { value: 96, unit: 'percent' },
60+
dewPoint: { value: 0.88, unit: 'C' },
61+
cloudiness: { value: 100, unit: 'percent' },
62+
conditions: { value: 'Cloudy', unit: 'string', original: 'Cloudy' },
63+
});
64+
});
65+
66+
it('normalizes unknown or missing weather codes into descriptive strings', async () => {
67+
mock
68+
.onGet('https://api.tomorrow.io/v4/weather/realtime')
69+
.reply(200, {
70+
data: {
71+
values: {
72+
humidity: 40,
73+
},
74+
},
75+
});
76+
77+
const data = await provider.getWeather(lat, lng);
78+
79+
expect(data).toEqual({
80+
humidity: { value: 40, unit: 'percent' },
81+
conditions: { value: 'Unknown', unit: 'string', original: 'Code -1' },
82+
});
83+
});
84+
85+
it('records failure metadata when Tomorrow.io responds with an error', async () => {
86+
class TestReporter implements ProviderOutcomeReporter {
87+
public events: Array<{ provider: string; outcome: ProviderCallOutcome }> = [];
88+
record(provider: string, outcome: ProviderCallOutcome): void {
89+
this.events.push({ provider, outcome });
90+
}
91+
}
92+
93+
const reporter = new TestReporter();
94+
const original = defaultOutcomeReporter;
95+
setDefaultOutcomeReporter(reporter);
96+
97+
mock
98+
.onGet('https://api.tomorrow.io/v4/weather/realtime')
99+
.reply(503);
100+
101+
try {
102+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Request failed with status code 503');
103+
expect(reporter.events).toHaveLength(1);
104+
expect(reporter.events[0]).toEqual({
105+
provider: 'tomorrow',
106+
outcome: expect.objectContaining({
107+
ok: false,
108+
code: 'UpstreamError',
109+
status: 503,
110+
}),
111+
});
112+
} finally {
113+
setDefaultOutcomeReporter(original);
114+
}
115+
});
116+
117+
it('throws when essential weather values are missing', async () => {
118+
mock
119+
.onGet('https://api.tomorrow.io/v4/weather/realtime')
120+
.reply(200, { data: { values: {} } });
121+
122+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Invalid weather data');
123+
});
124+
125+
it('throws when values block is missing entirely', async () => {
126+
mock
127+
.onGet('https://api.tomorrow.io/v4/weather/realtime')
128+
.reply(200, { data: {} });
129+
130+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Invalid weather data');
131+
});
132+
133+
it('wraps unexpected rejection payloads in a descriptive error', async () => {
134+
mock.restore();
135+
const spy = jest.spyOn(axios, 'get').mockRejectedValueOnce(undefined);
136+
137+
await expect(provider.getWeather(lat, lng)).rejects.toThrow('Failed to fetch Tomorrow.io data');
138+
139+
spy.mockRestore();
140+
mock = new MockAdapter(axios);
141+
});
142+
});

0 commit comments

Comments
 (0)