Refactor WeatherKit client to injectable interface

- Add WeatherKitClient interface and DefaultWeatherKitClient class
- WeatherKitDataSource accepts either client or credentials
- Simplify tests by injecting mock client directly
- Update fixture generation script to use new client class

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-01-17 01:14:18 +00:00
parent 850d1925b6
commit 6cf147989f
6 changed files with 6548 additions and 108 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import { fetchWeather } from "../src/weatherkit" import { DefaultWeatherKitClient } from "../src/weatherkit"
function loadEnv(): Record<string, string> { function loadEnv(): Record<string, string> {
const content = require("fs").readFileSync(".env", "utf-8") const content = require("fs").readFileSync(".env", "utf-8")
@@ -26,12 +26,12 @@ function loadEnv(): Record<string, string> {
const env = loadEnv() const env = loadEnv()
const credentials = { const client = new DefaultWeatherKitClient({
privateKey: env.WEATHERKIT_PRIVATE_KEY!, privateKey: env.WEATHERKIT_PRIVATE_KEY!,
keyId: env.WEATHERKIT_KEY_ID!, keyId: env.WEATHERKIT_KEY_ID!,
teamId: env.WEATHERKIT_TEAM_ID!, teamId: env.WEATHERKIT_TEAM_ID!,
serviceId: env.WEATHERKIT_SERVICE_ID!, serviceId: env.WEATHERKIT_SERVICE_ID!,
} })
const locations = { const locations = {
sanFrancisco: { lat: 37.7749, lng: -122.4194 }, sanFrancisco: { lat: 37.7749, lng: -122.4194 },
@@ -40,10 +40,10 @@ const locations = {
async function main() { async function main() {
console.log("Fetching weather data for San Francisco...") console.log("Fetching weather data for San Francisco...")
const response = await fetchWeather( const response = await client.fetch({
{ credentials }, lat: locations.sanFrancisco.lat,
{ lat: locations.sanFrancisco.lat, lng: locations.sanFrancisco.lng }, lng: locations.sanFrancisco.lng,
) })
const fixture = { const fixture = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),

View File

@@ -1,11 +1,12 @@
import type { Context } from "@aris/core" import type { Context } from "@aris/core"
import { describe, expect, mock, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherKitDataSource, Units } from "./data-source" import { WeatherKitDataSource, Units } from "./data-source"
import { WeatherFeedItemType } from "./feed-items" import { WeatherFeedItemType } from "./feed-items"
import * as weatherkit from "./weatherkit"
const mockCredentials = { const mockCredentials = {
privateKey: "mock", privateKey: "mock",
@@ -14,6 +15,10 @@ const mockCredentials = {
serviceId: "mock", serviceId: "mock",
} }
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response,
})
const createMockContext = (location?: { lat: number; lng: number }): Context => ({ const createMockContext = (location?: { lat: number; lng: number }): Context => ({
time: new Date("2026-01-17T00:00:00Z"), time: new Date("2026-01-17T00:00:00Z"),
location: location ? { ...location, accuracy: 10 } : undefined, location: location ? { ...location, accuracy: 10 } : undefined,
@@ -32,6 +37,12 @@ describe("WeatherKitDataSource", () => {
expect(dataSource.type).toBe(WeatherFeedItemType.current) expect(dataSource.type).toBe(WeatherFeedItemType.current)
}) })
test("throws error if neither client nor credentials provided", () => {
expect(() => new WeatherKitDataSource({})).toThrow(
"Either client or credentials must be provided",
)
})
}) })
describe("WeatherKitDataSource with fixture", () => { describe("WeatherKitDataSource with fixture", () => {
@@ -103,19 +114,11 @@ describe("unit conversion", () => {
}) })
}) })
describe("query() with mocked API", () => { describe("query() with mocked client", () => {
const mockFetchWeather = mock(() => const mockClient = createMockClient(fixture.response as WeatherKitResponse)
Promise.resolve(fixture.response as weatherkit.WeatherKitResponse),
)
test("transforms API response into feed items", async () => { test("transforms API response into feed items", async () => {
mock.module("./weatherkit", () => ({ const dataSource = new WeatherKitDataSource({ client: mockClient })
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context) const items = await dataSource.query(context)
@@ -127,14 +130,8 @@ describe("query() with mocked API", () => {
}) })
test("applies hourly and daily limits", async () => { test("applies hourly and daily limits", async () => {
mock.module("./weatherkit", () => ({
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ const dataSource = new WeatherKitDataSource({
credentials: mockCredentials, client: mockClient,
hourlyLimit: 3, hourlyLimit: 3,
dailyLimit: 2, dailyLimit: 2,
}) })
@@ -150,13 +147,7 @@ describe("query() with mocked API", () => {
}) })
test("sets timestamp from context.time", async () => { test("sets timestamp from context.time", async () => {
mock.module("./weatherkit", () => ({ const dataSource = new WeatherKitDataSource({ client: mockClient })
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const queryTime = new Date("2026-01-17T12:00:00Z") const queryTime = new Date("2026-01-17T12:00:00Z")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
context.time = queryTime context.time = queryTime
@@ -169,13 +160,7 @@ describe("query() with mocked API", () => {
}) })
test("converts temperatures to imperial", async () => { test("converts temperatures to imperial", async () => {
mock.module("./weatherkit", () => ({ const dataSource = new WeatherKitDataSource({ client: mockClient })
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource, Units } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const metricItems = await dataSource.query(context, { units: Units.metric }) const metricItems = await dataSource.query(context, { units: Units.metric })
@@ -187,7 +172,6 @@ describe("query() with mocked API", () => {
expect(metricCurrent).toBeDefined() expect(metricCurrent).toBeDefined()
expect(imperialCurrent).toBeDefined() expect(imperialCurrent).toBeDefined()
// Imperial temp should be higher (F > C for typical weather temps)
const metricTemp = (metricCurrent!.data as { temperature: number }).temperature const metricTemp = (metricCurrent!.data as { temperature: number }).temperature
const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature
@@ -197,13 +181,7 @@ describe("query() with mocked API", () => {
}) })
test("assigns priority based on weather conditions", async () => { test("assigns priority based on weather conditions", async () => {
mock.module("./weatherkit", () => ({ const dataSource = new WeatherKitDataSource({ client: mockClient })
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context) const items = await dataSource.query(context)
@@ -220,13 +198,7 @@ describe("query() with mocked API", () => {
}) })
test("generates unique IDs for each item", async () => { test("generates unique IDs for each item", async () => {
mock.module("./weatherkit", () => ({ const dataSource = new WeatherKitDataSource({ client: mockClient })
...weatherkit,
fetchWeather: mockFetchWeather,
}))
const { WeatherKitDataSource } = await import("./data-source")
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context) const items = await dataSource.query(context)

View File

@@ -10,12 +10,13 @@ import {
} from "./feed-items" } from "./feed-items"
import { import {
ConditionCode, ConditionCode,
DefaultWeatherKitClient,
Severity, Severity,
fetchWeather,
type CurrentWeather, type CurrentWeather,
type DailyForecast, type DailyForecast,
type HourlyForecast, type HourlyForecast,
type WeatherAlert, type WeatherAlert,
type WeatherKitClient,
type WeatherKitCredentials, type WeatherKitCredentials,
} from "./weatherkit" } from "./weatherkit"
@@ -27,7 +28,8 @@ export const Units = {
export type Units = (typeof Units)[keyof typeof Units] export type Units = (typeof Units)[keyof typeof Units]
export interface WeatherKitDataSourceOptions { export interface WeatherKitDataSourceOptions {
credentials: WeatherKitCredentials credentials?: WeatherKitCredentials
client?: WeatherKitClient
hourlyLimit?: number hourlyLimit?: number
dailyLimit?: number dailyLimit?: number
} }
@@ -41,12 +43,15 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
private readonly DEFAULT_DAILY_LIMIT = 7 private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.current readonly type = WeatherFeedItemType.current
private readonly credentials: WeatherKitCredentials private readonly client: WeatherKitClient
private readonly hourlyLimit: number private readonly hourlyLimit: number
private readonly dailyLimit: number private readonly dailyLimit: number
constructor(options: WeatherKitDataSourceOptions) { constructor(options: WeatherKitDataSourceOptions) {
this.credentials = options.credentials if (!options.client && !options.credentials) {
throw new Error("Either client or credentials must be provided")
}
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
} }
@@ -59,13 +64,10 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
const units = config.units ?? Units.metric const units = config.units ?? Units.metric
const timestamp = context.time const timestamp = context.time
const response = await fetchWeather( const response = await this.client.fetch({
{ credentials: this.credentials },
{
lat: context.location.lat, lat: context.location.lat,
lng: context.location.lng, lng: context.location.lng,
}, })
)
const items: WeatherFeedItem[] = [] const items: WeatherFeedItem[] = []

View File

@@ -26,10 +26,13 @@ export {
Certainty, Certainty,
PrecipitationType, PrecipitationType,
ConditionCode, ConditionCode,
DefaultWeatherKitClient,
type Severity as SeverityType, type Severity as SeverityType,
type Urgency as UrgencyType, type Urgency as UrgencyType,
type Certainty as CertaintyType, type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType, type PrecipitationType as PrecipitationTypeType,
type ConditionCode as ConditionCodeType, type ConditionCode as ConditionCodeType,
type WeatherKitCredentials, type WeatherKitCredentials,
type WeatherKitClient,
type WeatherKitQueryOptions,
} from "./weatherkit" } from "./weatherkit"

View File

@@ -3,13 +3,37 @@
import { type } from "arktype" import { type } from "arktype"
export async function fetchWeather( export interface WeatherKitCredentials {
options: WeatherKitClientOptions, privateKey: string
query: WeatherKitQueryOptions, keyId: string
): Promise<WeatherKitResponse> { teamId: string
const token = await generateJwt(options.credentials) serviceId: string
}
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(",") export interface WeatherKitQueryOptions {
lat: number
lng: number
language?: string
timezone?: string
}
export interface WeatherKitClient {
fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse>
}
export class DefaultWeatherKitClient implements WeatherKitClient {
private readonly credentials: WeatherKitCredentials
constructor(credentials: WeatherKitCredentials) {
this.credentials = credentials
}
async fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse> {
const token = await generateJwt(this.credentials)
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(
",",
)
const url = new URL( const url = new URL(
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`, `${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
@@ -37,24 +61,7 @@ export async function fetchWeather(
} }
return result return result
} }
export interface WeatherKitCredentials {
privateKey: string
keyId: string
teamId: string
serviceId: string
}
export interface WeatherKitClientOptions {
credentials: WeatherKitCredentials
}
export interface WeatherKitQueryOptions {
lat: number
lng: number
language?: string
timezone?: string
} }
export const Severity = { export const Severity = {