k-skill/scripts/test_fine_dust.py
Jeffrey (Dongkyu) Kim a3ef6ffac6 Make the fine-dust proxy easier to consume than the upstream APIs
The fine-dust lane now treats the public proxy as the default surface,
keeps a simple summarized report endpoint, and also exposes a narrow
AirKorea passthrough shape so callers can reuse upstream query patterns
without carrying service keys on the client side.

The skill instructions were trimmed down so the default path is obvious,
region-name guidance stays visible, and detailed implementation notes
move into feature docs instead of bloating the primary skill surface.

Constraint: Free-API proxy endpoints are intentionally public and must avoid embedding upstream secrets in the repo
Constraint: AirKorea station-info access can return 403 even when measurement access succeeds, so the report path needs a measurement-only fallback
Rejected: Keep proxy auth via shared token | contradicts the intended public free-API proxy policy
Rejected: Force all callers onto the summary endpoint only | passthrough compatibility is useful for direct HTTP consumers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the proxy allowlist narrow; if new upstream routes are exposed, document them explicitly rather than turning this into a generic open proxy
Tested: node --test scripts/skill-docs.test.js; npm run test --workspace k-skill-proxy; python3 -m unittest discover -s scripts -p test_fine_dust.py; live curls against /health, /v1/fine-dust/report, and /B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty
Not-tested: Fresh reboot validation of PM2/cloudflared persistence after the latest code-only changes
2026-03-28 17:14:18 +09:00

284 lines
12 KiB
Python

import io
import json
import pathlib
import unittest
from contextlib import redirect_stdout
from unittest import mock
import fine_dust
FIXTURES = pathlib.Path(__file__).with_name("fixtures")
def load_fixture(name):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
class FineDustTests(unittest.TestCase):
def test_wgs84_coordinates_are_converted_to_air_korea_tm(self):
tm_x, tm_y = fine_dust.wgs84_to_air_korea_tm(37.5665, 126.9780)
self.assertAlmostEqual(tm_x, 198245.053, places=3)
self.assertAlmostEqual(tm_y, 451586.838, places=3)
def test_pick_station_prefers_nearest_station_for_coordinates(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
lat=37.5665,
lon=126.9780,
)
self.assertEqual(station["stationName"], "중구")
def test_pick_station_prefers_specific_region_token_over_generic_city_token(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
region_hint="서울 강남구",
)
self.assertEqual(station["stationName"], "강남구")
def test_pick_station_falls_back_to_region_hint_without_coordinates(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
region_hint="강남",
)
self.assertEqual(station["stationName"], "강남구")
def test_build_report_combines_station_and_measurement_summary(self):
stations = load_fixture("fine-dust-stations.json")
measurements = load_fixture("fine-dust-measurements.json")
report = fine_dust.build_report(
station_items=fine_dust.extract_items(stations),
measurement_items=fine_dust.extract_items(measurements),
lat=37.5665,
lon=126.9780,
)
self.assertEqual(report["station_name"], "중구")
self.assertEqual(report["pm10"], {"value": "42", "grade": "보통"})
self.assertEqual(report["pm25"], {"value": "19", "grade": "보통"})
self.assertEqual(report["measured_at"], "2026-03-27 21:00")
def test_build_report_marks_khai_grade_unknown_when_api_omits_it(self):
report = fine_dust.build_report(
station_items=[{"stationName": "중구", "addr": "서울 중구 서소문로 124"}],
measurement_items=[
{
"stationName": "중구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": None,
}
],
station_name="중구",
)
self.assertEqual(report["khai_grade"], "정보없음")
def test_cli_report_supports_fixture_inputs(self):
station_path = FIXTURES / "fine-dust-stations.json"
measurement_path = FIXTURES / "fine-dust-measurements.json"
stdout = io.StringIO()
with redirect_stdout(stdout):
fine_dust.main([
"report",
"--station-file",
str(station_path),
"--measurement-file",
str(measurement_path),
"--lat",
"37.5665",
"--lon",
"126.9780",
])
rendered = stdout.getvalue()
self.assertIn("측정소: 중구", rendered)
self.assertIn("PM10: 42 (보통)", rendered)
self.assertIn("PM2.5: 19 (보통)", rendered)
def test_live_station_lookup_converts_lat_lon_before_nearby_request(self):
args = fine_dust.parse_args(["report", "--lat", "37.5665", "--lon", "126.9780"])
recorded_calls = []
def fake_fetch_json(url, params):
recorded_calls.append((url, params))
return {"response": {"body": {"items": [{"stationName": "중구", "addr": "서울 중구 서소문로 124"}]}}}
with (
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
payload = fine_dust.fetch_station_payload(args)
items = fine_dust.extract_items(payload)
self.assertEqual(items[0]["stationName"], "중구")
self.assertEqual(len(recorded_calls), 1)
request_url, request_params = recorded_calls[0]
self.assertTrue(request_url.endswith("/getNearbyMsrstnList"))
self.assertAlmostEqual(request_params["tmX"], 198245.053, places=3)
self.assertAlmostEqual(request_params["tmY"], 451586.838, places=3)
self.assertNotIn("dmX", request_params)
self.assertNotIn("dmY", request_params)
def test_live_station_lookup_falls_back_to_region_search_after_empty_nearby_result(self):
args = fine_dust.parse_args(["report", "--lat", "37.5665", "--lon", "126.9780", "--region-hint", "서울 강남구"])
recorded_calls = []
def fake_fetch_json(url, params):
recorded_calls.append((url, params))
if url.endswith("/getNearbyMsrstnList"):
return {"response": {"body": {"items": []}}}
if url.endswith("/getMsrstnList"):
return {"response": {"body": {"items": [{"stationName": "강남구", "addr": "서울 강남구 학동로 426"}]}}}
raise AssertionError(f"unexpected URL: {url}")
with (
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
payload = fine_dust.fetch_station_payload(args)
items = fine_dust.extract_items(payload)
self.assertEqual(items[0]["stationName"], "강남구")
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getNearbyMsrstnList", "getMsrstnList"])
fallback_params = recorded_calls[1][1]
self.assertEqual(fallback_params["addr"], "서울 강남구")
def test_cli_json_report_marks_region_fallback_when_nearby_lookup_is_empty(self):
stdout = io.StringIO()
def fake_fetch_json(url, params):
if url.endswith("/getNearbyMsrstnList"):
return {"response": {"body": {"items": []}}}
if url.endswith("/getMsrstnList"):
return {"response": {"body": {"items": [{"stationName": "강남구", "addr": "서울 강남구 학동로 426"}]}}}
if url.endswith("/getMsrstnAcctoRltmMesureDnsty"):
return {
"response": {
"body": {
"items": [
{
"stationName": "강남구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": "2",
}
]
}
}
}
raise AssertionError(f"unexpected URL: {url}")
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "off"}, clear=False),
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
fine_dust.main(
[
"report",
"--lat",
"37.5665",
"--lon",
"126.9780",
"--region-hint",
"서울 강남구",
"--json",
]
)
rendered = json.loads(stdout.getvalue())
self.assertEqual(rendered["station_name"], "강남구")
self.assertEqual(rendered["lookup_mode"], "fallback")
def test_cli_json_report_uses_station_name_directly_when_station_lookup_is_empty(self):
stdout = io.StringIO()
recorded_calls = []
def fake_fetch_json(url, params):
recorded_calls.append((url, params))
if url.endswith("/getMsrstnList"):
return {"response": {"body": {"items": []}}}
if url.endswith("/getMsrstnAcctoRltmMesureDnsty"):
return {
"response": {
"body": {
"items": [
{
"stationName": "중구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": "2",
}
]
}
}
}
raise AssertionError(f"unexpected URL: {url}")
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "off"}, clear=False),
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
fine_dust.main(["report", "--station-name", "중구", "--json"])
rendered = json.loads(stdout.getvalue())
self.assertEqual(rendered["station_name"], "중구")
self.assertIsNone(rendered["station_address"])
self.assertEqual(rendered["lookup_mode"], "fallback")
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getMsrstnList", "getMsrstnAcctoRltmMesureDnsty"])
self.assertEqual(recorded_calls[1][1]["stationName"], "중구")
def test_cli_json_report_prefers_proxy_when_proxy_base_url_is_configured(self):
stdout = io.StringIO()
proxy_report = {
"station_name": "강남구",
"station_address": "서울 강남구 학동로 426",
"lookup_mode": "fallback",
"measured_at": "2026-03-27 21:00",
"pm10": {"value": "42", "grade": "보통"},
"pm25": {"value": "19", "grade": "보통"},
"khai_grade": "보통",
"proxy": {"name": "k-skill-proxy"},
}
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "https://k-skill-proxy.nomadamas.org"}),
mock.patch.object(fine_dust, "fetch_proxy_report", return_value=proxy_report),
mock.patch.object(fine_dust, "fetch_station_lookup", side_effect=AssertionError("direct lookup should not run")),
):
fine_dust.main(["report", "--region-hint", "서울 강남구", "--json"])
rendered = json.loads(stdout.getvalue())
self.assertEqual(rendered["station_name"], "강남구")
self.assertEqual(rendered["proxy"]["name"], "k-skill-proxy")
if __name__ == "__main__":
unittest.main()