Clock_out_Time_Calculator/tests/test_holiday_api.py

174 lines
6.4 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, monkeypatch):
import utils.holiday_api as _ha
monkeypatch.setattr(_ha, '_SERVICE_KEY', 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93')
assert _ha.is_configured() is True
def test_key_empty(self, monkeypatch):
import utils.holiday_api as _ha
monkeypatch.setattr(_ha, '_SERVICE_KEY', '')
assert _ha.is_configured() is False