Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 85 additions & 12 deletions apps/web/src/routers/kiloclaw-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,35 +249,35 @@ describe('kiloclawRouter validateWeatherLocation', () => {
fetchSpy.mockRestore();
});

it('returns the format=3 preview with a readable nearest-area location', async () => {
it('requests metric weather for a non-US resolved location', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-test-${Math.random()}@example.com`,
});
fetchSpy
.mockResolvedValueOnce(wttrFormat3Response('Amsterdam: ☁️ +7°C'))
.mockResolvedValueOnce(
wttrLocationResponse({
areaName: 'Binnenstad',
region: 'North Holland',
country: 'Netherlands',
})
);
)
.mockResolvedValueOnce(wttrFormat3Response('Amsterdam: ☁️ +7°C'));
const caller = createCaller({ user });

const result = await caller.validateWeatherLocation({ location: ' Amsterdam ' });

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(fetchSpy).toHaveBeenNthCalledWith(
1,
'https://wttr.in/Amsterdam?format=3',
'https://wttr.in/Amsterdam?format=j1',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'curl/8.7.1' }),
signal: expect.any(AbortSignal),
})
);
expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://wttr.in/Amsterdam?format=j1',
'https://wttr.in/Amsterdam?format=3&m',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'curl/8.7.1' }),
signal: expect.any(AbortSignal),
Expand All @@ -290,26 +290,58 @@ describe('kiloclawRouter validateWeatherLocation', () => {
});
});

it('requests USCS weather for a US resolved location', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-us-test-${Math.random()}@example.com`,
});
fetchSpy
.mockResolvedValueOnce(
wttrLocationResponse({
areaName: 'Austin',
region: 'Texas',
country: 'United States of America',
})
)
.mockResolvedValueOnce(wttrFormat3Response('Austin: ☀️ +78°F'));
const caller = createCaller({ user });

const result = await caller.validateWeatherLocation({ location: 'Austin, Texas' });

expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://wttr.in/Austin%2C%20Texas?format=3&u',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'curl/8.7.1' }),
signal: expect.any(AbortSignal),
})
);
expect(result).toEqual({
location: 'Austin, United States of America',
currentWeatherText: '☀️ +78°F',
status: 'validated',
});
});

it('resolves coordinate locations to a readable display location', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-format-test-${Math.random()}@example.com`,
});
fetchSpy
.mockResolvedValueOnce(wttrFormat3Response('53.2167,6.5667: ☀️ +9°C\n'))
.mockResolvedValueOnce(
wttrLocationResponse({
areaName: 'Groningen',
region: 'Groningen',
country: 'Netherlands',
})
);
)
.mockResolvedValueOnce(wttrFormat3Response('53.2167,6.5667: ☀️ +9°C\n'));
const caller = createCaller({ user });

const result = await caller.validateWeatherLocation({ location: '53.2167,6.5667' });

expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://wttr.in/53.2167%2C6.5667?format=j1',
'https://wttr.in/53.2167%2C6.5667?format=3&m',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'curl/8.7.1' }),
signal: expect.any(AbortSignal),
Expand All @@ -322,11 +354,52 @@ describe('kiloclawRouter validateWeatherLocation', () => {
});
});

it('defaults to metric weather when the resolved country is unavailable', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-default-units-test-${Math.random()}@example.com`,
});
fetchSpy
.mockResolvedValueOnce(wttrFormat3Response('malformed location response'))
.mockResolvedValueOnce(wttrFormat3Response('Somewhere: ☁️ +7°C'));
const caller = createCaller({ user });

await caller.validateWeatherLocation({ location: 'Somewhere' });

expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://wttr.in/Somewhere?format=3&m',
expect.anything()
);
});

it('uses metric weather when country lookup is temporarily unavailable', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-country-outage-test-${Math.random()}@example.com`,
});
fetchSpy
.mockResolvedValueOnce(wttrFormat3Response('Bad Gateway', 502))
.mockResolvedValueOnce(wttrFormat3Response('Amsterdam: ☁️ +7°C'));
const caller = createCaller({ user });

await expect(caller.validateWeatherLocation({ location: 'Amsterdam' })).resolves.toEqual({
location: 'Amsterdam',
currentWeatherText: '☁️ +7°C',
status: 'validated',
});
expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://wttr.in/Amsterdam?format=3&m',
expect.anything()
);
});

it('rejects unknown locations without returning raw input', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-invalid-test-${Math.random()}@example.com`,
});
fetchSpy.mockResolvedValue(wttrFormat3Response('Unknown location; please try again.'));
fetchSpy.mockImplementation(async () =>
wttrFormat3Response('Unknown location; please try again.')
);
const caller = createCaller({ user });

await expect(caller.validateWeatherLocation({ location: 'not-a-real-place' })).rejects.toThrow(
Expand All @@ -338,7 +411,7 @@ describe('kiloclawRouter validateWeatherLocation', () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-malformed-test-${Math.random()}@example.com`,
});
fetchSpy.mockResolvedValue(wttrFormat3Response('☁️ +7°C'));
fetchSpy.mockImplementation(async () => wttrFormat3Response('☁️ +7°C'));
const caller = createCaller({ user });

await expect(caller.validateWeatherLocation({ location: ' Amsterdam ' })).resolves.toEqual({
Expand Down Expand Up @@ -381,7 +454,7 @@ describe('kiloclawRouter validateWeatherLocation', () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-service-error-test-${Math.random()}@example.com`,
});
fetchSpy.mockResolvedValue(wttrFormat3Response('Bad Gateway', 502));
fetchSpy.mockImplementation(async () => wttrFormat3Response('Bad Gateway', 502));
const caller = createCaller({ user });

await expect(caller.validateWeatherLocation({ location: 'Amsterdam' })).resolves.toEqual({
Expand All @@ -395,7 +468,7 @@ describe('kiloclawRouter validateWeatherLocation', () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-weather-not-found-status-test-${Math.random()}@example.com`,
});
fetchSpy.mockResolvedValue(wttrFormat3Response('Not Found', 404));
fetchSpy.mockImplementation(async () => wttrFormat3Response('Not Found', 404));
const caller = createCaller({ user });

await expect(caller.validateWeatherLocation({ location: 'not-a-real-place' })).rejects.toThrow(
Expand Down
27 changes: 20 additions & 7 deletions apps/web/src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,9 @@ const WTTR_LOCATION_TIMEOUT_MS = 4_000;
const WTTR_SERVICE_UNAVAILABLE_MESSAGE =
"wttr.in is down right now. We'll store your location as entered.";
const COORDINATE_LOCATION_PATTERN = /^-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?$/;
const US_WEATHER_COUNTRIES = new Set(['united states', 'united states of america']);

type WeatherUnits = 'm' | 'u';

type WeatherLocationValidationResult = {
location: string;
Expand Down Expand Up @@ -686,6 +689,17 @@ function formatCountryName(country: string): string {
return country.toLowerCase() === 'netherlands' ? 'The Netherlands' : country;
}

function weatherUnitsForCountry(country: string | null): WeatherUnits {
return country && US_WEATHER_COUNTRIES.has(country.toLowerCase()) ? 'u' : 'm';
}

function weatherUnitsForLocation(data: unknown): WeatherUnits {
const parsed = wttrLocationResponseSchema.safeParse(data);
if (!parsed.success) return 'm';

return weatherUnitsForCountry(wttrValue(parsed.data.nearest_area?.[0]?.country));
}

function isCoordinateLocation(location: string): boolean {
return COORDINATE_LOCATION_PATTERN.test(location.trim());
}
Expand Down Expand Up @@ -785,21 +799,20 @@ async function fetchWttr(location: string, query: string): Promise<WttrFetchResu
}
}

async function fetchReadableLocationName(
location: string,
preferredAreaName: string
): Promise<string | null> {
async function fetchWeatherLocationData(location: string): Promise<unknown> {
try {
const result = await fetchWttr(location, 'format=j1');
if (!result.ok || !result.response.ok) return null;
return normalizeWeatherLocation(await result.response.json(), preferredAreaName);
return await result.response.json();
} catch {
return null;
}
}

async function validateWeatherLocation(location: string): Promise<WeatherLocationValidationResult> {
const result = await fetchWttr(location, 'format=3');
const locationData = await fetchWeatherLocationData(location);
const units = weatherUnitsForLocation(locationData);
const result = await fetchWttr(location, `format=3&${units}`);
if (!result.ok) return wttrServiceUnavailableResult(location);

const response = result.response;
Expand All @@ -823,7 +836,7 @@ async function validateWeatherLocation(location: string): Promise<WeatherLocatio
return wttrServiceUnavailableResult(location);
}

const resolvedLocation = await fetchReadableLocationName(location, parsed.location);
const resolvedLocation = normalizeWeatherLocation(locationData, parsed.location);
return {
...parsed,
status: 'validated',
Expand Down