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

View File

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

View File

@@ -10,12 +10,13 @@ import {
} from "./feed-items"
import {
ConditionCode,
DefaultWeatherKitClient,
Severity,
fetchWeather,
type CurrentWeather,
type DailyForecast,
type HourlyForecast,
type WeatherAlert,
type WeatherKitClient,
type WeatherKitCredentials,
} from "./weatherkit"
@@ -27,7 +28,8 @@ export const Units = {
export type Units = (typeof Units)[keyof typeof Units]
export interface WeatherKitDataSourceOptions {
credentials: WeatherKitCredentials
credentials?: WeatherKitCredentials
client?: WeatherKitClient
hourlyLimit?: number
dailyLimit?: number
}
@@ -41,12 +43,15 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.current
private readonly credentials: WeatherKitCredentials
private readonly client: WeatherKitClient
private readonly hourlyLimit: number
private readonly dailyLimit: number
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.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 timestamp = context.time
const response = await fetchWeather(
{ credentials: this.credentials },
{
const response = await this.client.fetch({
lat: context.location.lat,
lng: context.location.lng,
},
)
})
const items: WeatherFeedItem[] = []

View File

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

View File

@@ -3,13 +3,37 @@
import { type } from "arktype"
export async function fetchWeather(
options: WeatherKitClientOptions,
query: WeatherKitQueryOptions,
): Promise<WeatherKitResponse> {
const token = await generateJwt(options.credentials)
export interface WeatherKitCredentials {
privateKey: string
keyId: string
teamId: string
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(
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
@@ -38,23 +62,6 @@ export async function fetchWeather(
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 = {