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> {
|
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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
lng: context.location.lng,
|
||||||
lat: context.location.lat,
|
})
|
||||||
lng: context.location.lng,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,42 +3,6 @@
|
|||||||
|
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
export async function fetchWeather(
|
|
||||||
options: WeatherKitClientOptions,
|
|
||||||
query: WeatherKitQueryOptions,
|
|
||||||
): Promise<WeatherKitResponse> {
|
|
||||||
const token = await generateJwt(options.credentials)
|
|
||||||
|
|
||||||
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(",")
|
|
||||||
|
|
||||||
const url = new URL(
|
|
||||||
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
|
|
||||||
)
|
|
||||||
url.searchParams.set("dataSets", dataSets)
|
|
||||||
if (query.timezone) {
|
|
||||||
url.searchParams.set("timezone", query.timezone)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json()
|
|
||||||
const result = weatherKitResponseSchema(json)
|
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherKitCredentials {
|
export interface WeatherKitCredentials {
|
||||||
privateKey: string
|
privateKey: string
|
||||||
keyId: string
|
keyId: string
|
||||||
@@ -46,10 +10,6 @@ export interface WeatherKitCredentials {
|
|||||||
serviceId: string
|
serviceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherKitClientOptions {
|
|
||||||
credentials: WeatherKitCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherKitQueryOptions {
|
export interface WeatherKitQueryOptions {
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
@@ -57,6 +17,53 @@ export interface WeatherKitQueryOptions {
|
|||||||
timezone?: 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}`,
|
||||||
|
)
|
||||||
|
url.searchParams.set("dataSets", dataSets)
|
||||||
|
if (query.timezone) {
|
||||||
|
url.searchParams.set("timezone", query.timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const result = weatherKitResponseSchema(json)
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Severity = {
|
export const Severity = {
|
||||||
Minor: "minor",
|
Minor: "minor",
|
||||||
Moderate: "moderate",
|
Moderate: "moderate",
|
||||||
|
|||||||
Reference in New Issue
Block a user