k-skill/scripts/test_seoul_bike.py
Jeffrey (Dongkyu) Kim b4a15406cf Prevent Seoul Bike upstream errors from masquerading as empty availability
Constraint: Seoul Open API can return application-level error JSON with HTTP 200, so proxy routes must inspect RESULT envelopes before caching or normalizing rows.
Rejected: Treating missing rentBikeStatus.row as an empty success | it masks quota/service failures and caches false no-station results.
Confidence: high
Scope-risk: narrow
Directive: Preserve non-cacheable proxy error behavior for Seoul Open API semantic failures across realtime, stations, and nearby routes.
Tested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; local fake-proxy seoul_bike.py nearby smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0j0fIum:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli" npm run ci; architect review APPROVED.
Not-tested: Live Seoul Open API error response from production service.
2026-05-21 15:39:32 +09:00

151 lines
5.3 KiB
Python

import contextlib
import importlib.util
import io
import json
import pathlib
import unittest
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "seoul-bike" / "scripts" / "seoul_bike.py"
spec = importlib.util.spec_from_file_location("seoul_bike", MODULE_PATH)
seoul_bike = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(seoul_bike)
NEARBY_PAYLOAD = {
"query": {"latitude": 37.5717, "longitude": 126.9763, "radius_m": 500, "limit": 2},
"count": 2,
"items": [
{
"station_id": "ST-101",
"station_name": "101. 광화문역 1번출구 앞",
"available_bikes": 4,
"empty_docks": 11,
"rack_total_count": 15,
"shared_percent": 27,
"distance_m": 0,
"latitude": 37.5717,
"longitude": 126.9763,
},
{
"station_id": "ST-102",
"station_name": "102. 세종대로 앞",
"available_bikes": 0,
"empty_docks": 12,
"rack_total_count": 12,
"shared_percent": 0,
"distance_m": 80,
"latitude": 37.5720,
"longitude": 126.9770,
},
],
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
REALTIME_PAYLOAD = {
"rentBikeStatus": {
"row": [
{
"stationId": "ST-101",
"stationName": "101. 광화문역 1번출구 앞",
"rackTotCnt": "15",
"parkingBikeTotCnt": "4",
"shared": "27",
"stationLatitude": "37.5717",
"stationLongitude": "126.9763",
}
]
},
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
class SeoulBikePayloadTest(unittest.TestCase):
def test_summarize_nearby_includes_bikes_docks_distance_and_timestamp(self):
lines = seoul_bike.format_nearby(NEARBY_PAYLOAD)
joined = "\n".join(lines)
self.assertIn("101. 광화문역 1번출구 앞", joined)
self.assertIn("대여 가능 4대", joined)
self.assertIn("빈 거치대 11개", joined)
self.assertIn("0m", joined)
self.assertIn("조회 시각: 2026-05-21T06:10:00.000Z", joined)
def test_search_realtime_filters_station_names_and_reports_empty_docks(self):
matches = seoul_bike.filter_realtime_rows(REALTIME_PAYLOAD, "광화문", limit=5)
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["station_id"], "ST-101")
self.assertEqual(matches[0]["available_bikes"], 4)
self.assertEqual(matches[0]["empty_docks"], 11)
def test_search_fetches_all_realtime_pages_before_filtering(self):
first = {
"rentBikeStatus": {
"list_total_count": 2,
"row": [{"stationId": "ST-001", "stationName": "001. 첫 페이지", "rackTotCnt": "1", "parkingBikeTotCnt": "1"}],
}
}
second = {
"rentBikeStatus": {
"list_total_count": 2,
"row": [{"stationId": "ST-999", "stationName": "999. 마지막 광화문", "rackTotCnt": "3", "parkingBikeTotCnt": "2"}],
}
}
with mock.patch.object(seoul_bike, "fetch_json", side_effect=[first, second]) as fetch_json:
rows = seoul_bike.fetch_realtime_pages(1, 1)
self.assertEqual([row["stationId"] for row in rows], ["ST-001", "ST-999"])
self.assertEqual(fetch_json.call_count, 2)
def test_cli_search_prints_realtime_lookup_timestamp(self):
payload = {
"rentBikeStatus": {
"list_total_count": 1,
"row": [
{
"stationId": "ST-101",
"stationName": "101. 광화문역 1번출구 앞",
"rackTotCnt": "15",
"parkingBikeTotCnt": "4",
}
],
},
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
with mock.patch.object(seoul_bike, "fetch_json", return_value=payload):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = seoul_bike.main(["search", "광화문"])
self.assertEqual(exit_code, 0)
self.assertIn("조회 시각: 2026-05-21T06:10:00.000Z", stdout.getvalue())
def test_cli_nearby_prints_json_when_requested(self):
with mock.patch.object(seoul_bike, "fetch_json", return_value=NEARBY_PAYLOAD):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = seoul_bike.main([
"nearby",
"--lat",
"37.5717",
"--lon",
"126.9763",
"--json",
])
self.assertEqual(exit_code, 0)
body = json.loads(stdout.getvalue())
self.assertEqual(body["items"][0]["station_id"], "ST-101")
def test_proxy_base_url_defaults_to_hosted_proxy(self):
with mock.patch.dict(seoul_bike.os.environ, {}, clear=True):
self.assertEqual(seoul_bike.get_proxy_base_url(), "https://k-skill-proxy.nomadamas.org")
if __name__ == "__main__":
unittest.main()