""" 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'error' 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