mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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.
151 lines
5.3 KiB
Python
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()
|