"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단.""" import asyncio from unittest.mock import patch, MagicMock import pytest import httpx from app.dsm_client import create_share_link, DSMError @pytest.fixture(autouse=True) def _no_backoff(monkeypatch): """retry 백오프 sleep 제거 — 테스트 속도.""" from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_BACKOFF_SEC", 0.0) @pytest.fixture(autouse=True) def _dsm_env(monkeypatch): monkeypatch.setenv("DSM_HOST", "https://test-nas:5001") monkeypatch.setenv("DSM_USER", "test-user") monkeypatch.setenv("DSM_PASS", "test-pass") from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001") monkeypatch.setattr(dsm_client, "DSM_USER", "test-user") monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass") def _make_response(json_data, status_code=200): """httpx.Response mock.""" mock = MagicMock(spec=httpx.Response) mock.json.return_value = json_data mock.status_code = status_code mock.raise_for_status = MagicMock() return mock def test_create_share_link_login_logout(): """login → Sharing.create → logout 순서가 보장되어야 한다.""" call_order = [] async def fake_get(self, url, *, params=None, **kw): api = (params or {}).get("api", "") method = (params or {}).get("method", "") call_order.append(f"{api}.{method}") if api == "SYNO.API.Auth" and method == "login": return _make_response({"success": True, "data": {"sid": "fake-sid"}}) if api == "SYNO.API.Auth" and method == "logout": return _make_response({"success": True}) if api == "SYNO.FileStation.Sharing" and method == "create": return _make_response({ "success": True, "data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]}, }) return _make_response({"success": False, "error": "unexpected"}) with patch.object(httpx.AsyncClient, "get", new=fake_get): url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600)) assert url == "https://test-nas:5001/sharing/abc" assert call_order == [ "SYNO.API.Auth.login", "SYNO.FileStation.Sharing.create", "SYNO.API.Auth.logout", ] def test_create_share_link_returns_url_and_expiry(): """응답 파싱 — links[0].url 사용.""" async def fake_get(self, url, *, params=None, **kw): method = (params or {}).get("method", "") if method == "login": return _make_response({"success": True, "data": {"sid": "sid"}}) if method == "create": return _make_response({ "success": True, "data": {"links": [{"url": "https://nas/sharing/xyz"}]}, }) return _make_response({"success": True}) with patch.object(httpx.AsyncClient, "get", new=fake_get): url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200)) assert url == "https://nas/sharing/xyz" assert expires_at is not None def test_dsm_login_failure_raises(): """login API success=False → DSMError.""" async def fake_get(self, url, *, params=None, **kw): return _make_response({"success": False, "error": {"code": 400}}) with patch.object(httpx.AsyncClient, "get", new=fake_get): with pytest.raises(DSMError, match="login 실패"): asyncio.run(create_share_link("/volume1/test/file.zip")) def test_dsm_share_failure_logs_out(): """Sharing.create 실패해도 logout 호출 (try/finally).""" call_order = [] async def fake_get(self, url, *, params=None, **kw): method = (params or {}).get("method", "") call_order.append(method) if method == "login": return _make_response({"success": True, "data": {"sid": "sid"}}) if method == "create": return _make_response({"success": False, "error": {"code": 401}}) if method == "logout": return _make_response({"success": True}) return _make_response({"success": False}) with patch.object(httpx.AsyncClient, "get", new=fake_get): with pytest.raises(DSMError, match="Sharing.create 실패"): asyncio.run(create_share_link("/volume1/test/file.zip")) assert "login" in call_order assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)" def test_retry_on_5xx_then_success(monkeypatch): """첫 호출 5xx → retry → 두 번째 200으로 성공.""" from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3) login_calls = {"n": 0} async def fake_get(self, url, *, params=None, **kw): method = (params or {}).get("method", "") if method == "login": login_calls["n"] += 1 if login_calls["n"] == 1: return _make_response({}, status_code=503) return _make_response({"success": True, "data": {"sid": "sid-after-retry"}}) if method == "create": return _make_response({ "success": True, "data": {"links": [{"url": "https://nas/sharing/retry"}]}, }) return _make_response({"success": True}) with patch.object(httpx.AsyncClient, "get", new=fake_get): url, _ = asyncio.run(create_share_link("/volume1/x.zip")) assert url == "https://nas/sharing/retry" assert login_calls["n"] == 2, "5xx 응답에 대해 retry가 동작해야 함" def test_retry_exhausts_on_persistent_5xx(monkeypatch): """5xx가 MAX_RETRIES 동안 계속되면 DSMError로 raise.""" from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 2) login_calls = {"n": 0} async def fake_get(self, url, *, params=None, **kw): method = (params or {}).get("method", "") if method == "login": login_calls["n"] += 1 return _make_response({}, status_code=503) return _make_response({"success": True}) with patch.object(httpx.AsyncClient, "get", new=fake_get): with pytest.raises(DSMError, match="재시도"): asyncio.run(create_share_link("/volume1/x.zip")) assert login_calls["n"] == 2, f"MAX_RETRIES만큼 시도해야 함 (실제: {login_calls['n']})" def test_retry_on_transport_error_then_success(monkeypatch): """httpx.ConnectError → retry → 성공.""" from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3) login_calls = {"n": 0} async def fake_get(self, url, *, params=None, **kw): method = (params or {}).get("method", "") if method == "login": login_calls["n"] += 1 if login_calls["n"] == 1: raise httpx.ConnectError("connection refused") return _make_response({"success": True, "data": {"sid": "sid"}}) if method == "create": return _make_response({ "success": True, "data": {"links": [{"url": "https://nas/sharing/tr"}]}, }) return _make_response({"success": True}) with patch.object(httpx.AsyncClient, "get", new=fake_get): url, _ = asyncio.run(create_share_link("/volume1/x.zip")) assert url == "https://nas/sharing/tr" assert login_calls["n"] == 2 def test_no_retry_on_4xx(monkeypatch): """4xx (영구 오류)는 retry 없이 즉시 raise_for_status.""" from app import dsm_client monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3) login_calls = {"n": 0} def _raise_4xx(): raise httpx.HTTPStatusError( "client error", request=MagicMock(), response=MagicMock(status_code=403), ) async def fake_get(self, url, *, params=None, **kw): login_calls["n"] += 1 resp = MagicMock(spec=httpx.Response) resp.status_code = 403 resp.json.return_value = {} resp.raise_for_status = _raise_4xx return resp with patch.object(httpx.AsyncClient, "get", new=fake_get): with pytest.raises(httpx.HTTPStatusError): asyncio.run(create_share_link("/volume1/x.zip")) assert login_calls["n"] == 1, "4xx는 retry 없이 즉시 raise"