\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0 \uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback. - utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item) - Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain - migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654 (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var) - Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8 Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green \ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green \uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5, fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
"""
|
|
utils.holiday_api 단위 테스트.
|
|
|
|
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from utils.holiday_api import (
|
|
fetch_korean_holidays, _parse_response, is_configured,
|
|
)
|
|
|
|
|
|
def _ok_response(items):
|
|
"""API 정상 응답 형식 빌드."""
|
|
return {
|
|
'response': {
|
|
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
|
|
'body': {
|
|
'items': {'item': items} if items else {'item': []},
|
|
'numOfRows': 100,
|
|
'pageNo': 1,
|
|
'totalCount': len(items) if isinstance(items, list) else 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
class TestParseResponse:
|
|
def test_multiple_items(self):
|
|
items = [
|
|
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
|
|
'locdate': 20260501, 'seq': 1},
|
|
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
|
|
'locdate': 20260505, 'seq': 1},
|
|
]
|
|
out = _parse_response(_ok_response(items))
|
|
assert len(out) == 2
|
|
assert out[0]['date'] == '2026-05-01'
|
|
assert out[0]['name'] == '근로자의 날'
|
|
assert out[0]['is_holiday'] is True
|
|
assert out[1]['date'] == '2026-05-05'
|
|
|
|
def test_single_item_as_dict(self):
|
|
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
|
|
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
|
|
data = {
|
|
'response': {
|
|
'header': {'resultCode': '00'},
|
|
'body': {'items': {'item': item}, 'totalCount': 1},
|
|
}
|
|
}
|
|
out = _parse_response(data)
|
|
assert len(out) == 1
|
|
assert out[0]['name'] == '근로자의 날'
|
|
|
|
def test_empty_year(self):
|
|
# totalCount=0 같은 정상 빈 응답
|
|
data = {
|
|
'response': {
|
|
'header': {'resultCode': '00'},
|
|
'body': {'items': '', 'totalCount': 0},
|
|
}
|
|
}
|
|
assert _parse_response(data) == []
|
|
|
|
def test_error_result_code(self):
|
|
data = {
|
|
'response': {
|
|
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
|
|
'body': {},
|
|
}
|
|
}
|
|
assert _parse_response(data) is None
|
|
|
|
def test_isholiday_n_filtered_at_caller_level(self):
|
|
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
|
|
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
|
|
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
|
|
out = _parse_response(_ok_response(items))
|
|
assert len(out) == 1
|
|
assert out[0]['is_holiday'] is False
|
|
|
|
def test_locdate_str_form(self):
|
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
|
|
out = _parse_response(_ok_response(items))
|
|
assert out[0]['date'] == '2026-05-01'
|
|
|
|
def test_invalid_locdate_skipped(self):
|
|
items = [
|
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
|
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
|
|
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
|
|
]
|
|
out = _parse_response(_ok_response(items))
|
|
assert len(out) == 1 # 정상 1개만
|
|
assert out[0]['name'] == '근로자의 날'
|
|
|
|
def test_missing_required_fields_skipped(self):
|
|
items = [
|
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
|
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
|
|
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
|
|
]
|
|
out = _parse_response(_ok_response(items))
|
|
assert len(out) == 1
|
|
|
|
def test_malformed_response_returns_none(self):
|
|
# response 구조 자체가 깨진 경우
|
|
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
|
|
# 위는 implementation-dependent — 둘 다 합리적
|
|
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
|
|
assert _parse_response({}) is None
|
|
|
|
|
|
class TestFetchNetwork:
|
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
|
def test_success(self, mock_urlopen):
|
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
mock_urlopen.return_value = resp
|
|
|
|
out = fetch_korean_holidays(2026)
|
|
assert out is not None
|
|
assert len(out) == 1
|
|
assert out[0]['date'] == '2026-05-01'
|
|
|
|
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
|
|
req = mock_urlopen.call_args[0][0]
|
|
assert 'serviceKey=' in req.full_url
|
|
assert 'solYear=2026' in req.full_url
|
|
assert '_type=json' in req.full_url
|
|
|
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
|
def test_network_error_returns_none(self, mock_urlopen):
|
|
import urllib.error
|
|
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
|
assert fetch_korean_holidays(2026) is None
|
|
|
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
|
def test_timeout_returns_none(self, mock_urlopen):
|
|
mock_urlopen.side_effect = TimeoutError('slow')
|
|
assert fetch_korean_holidays(2026) is None
|
|
|
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
|
def test_invalid_json_returns_none(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
resp.read.return_value = b'<html>error</html>'
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
mock_urlopen.return_value = resp
|
|
assert fetch_korean_holidays(2026) is None
|
|
|
|
|
|
class TestConfigured:
|
|
def test_key_set(self):
|
|
assert is_configured() is True
|