Files
homeclaw/tests/geocode-retry.test.ts
T
kimandClaude Sonnet 5 d51c40484a feat: 의료 어시스턴트 앱 + Home Assistant 임베드 앱 추가
법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가
(routes-doctor-cases.ts + doctor-app.html + WOPI 라우트). Home Assistant를
iframe으로 띄우는 얇은 래퍼 앱 추가(ha.applecherry.net, 2026-08-01 통합
완료된 것 URL만 새로 노출). weather_openmeteo의 요청 성형/리포트 포맷팅
로직을 weather-openmeteo-format.ts로 분리(순수함수라 테스트 가능,
2026-07-29 있었던 타임존/롤링윈도우/daily-in-hourly 버그 3건 회귀 방지
테스트 포함) + geocode 재시도 테스트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:16:01 +09:00

84 lines
3.0 KiB
TypeScript

/**
* geocode-retry.test.ts
*
* Pinned to the real 2026-07-29 failure: a `weather_openmeteo({"location":"Madrid"})`
* call in the middle of a 6-city burst returned "위치를 찾을 수 없습니다: Madrid"
* while the 5 surrounding calls and 20/20 direct retries succeeded. The geocoding
* API threw a one-off transient blip; geocodeCity had no retry, so that single
* blip hardened into a permanent "city not found" and the model dropped Madrid.
*
* These tests pin the retry contract: transient failures (throw / non-2xx) get
* retried, a genuine 200-with-empty is answered immediately without wasting retries.
*/
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { geocodeCity } from '../src/tools/weather.js';
const REAL = (name: string) => ({
ok: true,
status: 200,
json: async () => ({ results: [{ name, country: '스페인', country_code: 'ES', latitude: 40.4165, longitude: -3.70256, population: 3255944 }] }),
});
describe('geocodeCity retry', () => {
let origFetch: typeof fetch;
let calls: number;
let plan: Array<() => Promise<any>>;
beforeEach(() => {
origFetch = globalThis.fetch;
calls = 0;
plan = [];
globalThis.fetch = (async () => {
const step = plan[calls++] ?? plan[plan.length - 1];
return step();
}) as any;
});
afterEach(() => { globalThis.fetch = origFetch; });
test('succeeds on first try, calls fetch once', async () => {
plan = [() => Promise.resolve(REAL('Madrid'))];
const r = await geocodeCity('Madrid');
assert.equal(calls, 1);
assert.equal(r?.lat, 40.4165);
assert.equal(r?.displayName, 'Madrid, 스페인');
});
test('retries past a transient 5xx then succeeds', async () => {
plan = [
() => Promise.resolve({ ok: false, status: 503, json: async () => ({}) }),
() => Promise.resolve({ ok: false, status: 502, json: async () => ({}) }),
() => Promise.resolve(REAL('Madrid')),
];
const r = await geocodeCity('Madrid');
assert.equal(calls, 3, `expected 3 fetches, got ${calls}`);
assert.ok(r, 'should resolve on the third attempt');
assert.equal(r?.lat, 40.4165);
});
test('retries past a network throw then succeeds', async () => {
plan = [
() => Promise.reject(new Error('ECONNRESET')),
() => Promise.resolve(REAL('Madrid')),
];
const r = await geocodeCity('Madrid');
assert.equal(calls, 2);
assert.ok(r);
assert.equal(r?.lat, 40.4165);
});
test('200 with no results = genuine not found, no retry', async () => {
plan = [() => Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) })];
const r = await geocodeCity('zzqxnotacity');
assert.equal(calls, 1, 'empty results must not be retried');
assert.equal(r, null);
});
test('three transient failures exhaust retries and return null', async () => {
plan = [() => Promise.reject(new Error('timeout'))];
const r = await geocodeCity('Madrid');
assert.equal(calls, 3, `expected 3 attempts, got ${calls}`);
assert.equal(r, null);
});
});