- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키 - 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용 - MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출 - 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등) - pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122) - README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
111 lines
3.5 KiB
Python
111 lines
3.5 KiB
Python
"""
|
|
utils.crash_handler 단위 테스트.
|
|
|
|
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from core.database import Database
|
|
from utils.crash_handler import _log_crash, _send_to_gitea
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
|
|
if os.path.exists(p):
|
|
os.remove(p)
|
|
d = Database(p)
|
|
yield d
|
|
try:
|
|
os.remove(p)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class TestLogCrash:
|
|
def test_creates_table_and_inserts(self, db):
|
|
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
|
|
conn = db.get_connection()
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
|
|
row = cur.fetchone()
|
|
conn.close()
|
|
assert row[0] == 'TestExc'
|
|
assert row[1] == 'msg'
|
|
assert row[2] == 'v2.6.0'
|
|
|
|
def test_table_idempotent_creation(self, db):
|
|
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
|
|
_log_crash(db, 'A', 'a', 't', 'v1')
|
|
_log_crash(db, 'B', 'b', 't', 'v1')
|
|
conn = db.get_connection()
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM crash_log")
|
|
count = cur.fetchone()[0]
|
|
conn.close()
|
|
assert count == 2
|
|
|
|
def test_silent_on_db_error(self, db):
|
|
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
|
|
broken = MagicMock()
|
|
broken.get_connection.side_effect = RuntimeError('boom')
|
|
# raise되면 안 됨
|
|
_log_crash(broken, 'X', 'x', 'tb', 'v')
|
|
|
|
|
|
class TestSendToGitea:
|
|
@patch('utils.crash_handler.urllib.request.urlopen')
|
|
def test_success(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
resp.status = 201
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
mock_urlopen.return_value = resp
|
|
|
|
ok = _send_to_gitea('fake_token', 'title', 'body')
|
|
assert ok is True
|
|
|
|
req = mock_urlopen.call_args[0][0]
|
|
# PAT 헤더 확인
|
|
assert req.headers.get('Authorization') == 'token fake_token'
|
|
# User-Agent 위장
|
|
assert 'Mozilla' in req.headers.get('User-agent', '')
|
|
|
|
@patch('utils.crash_handler.urllib.request.urlopen')
|
|
def test_4xx_returns_false(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
resp.status = 401
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
mock_urlopen.return_value = resp
|
|
|
|
assert _send_to_gitea('bad', 't', 'b') is False
|
|
|
|
@patch('utils.crash_handler.urllib.request.urlopen')
|
|
def test_network_error(self, mock_urlopen):
|
|
import urllib.error
|
|
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
|
assert _send_to_gitea('t', 't', 'b') is False
|
|
|
|
@patch('utils.crash_handler.urllib.request.urlopen')
|
|
def test_payload_json(self, mock_urlopen):
|
|
import json as _json
|
|
resp = MagicMock()
|
|
resp.status = 201
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
mock_urlopen.return_value = resp
|
|
|
|
_send_to_gitea('tok', 'TITLE', 'BODY')
|
|
req = mock_urlopen.call_args[0][0]
|
|
body = _json.loads(req.data.decode('utf-8'))
|
|
assert body['title'] == 'TITLE'
|
|
assert body['body'] == 'BODY'
|