mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`,
|
||||
@@ -37,24 +61,7 @@ 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 = {
|
||||
|
||||
Reference in New Issue
Block a user