Skip to content

Commit 9ff16bb

Browse files
committed
fix: tests for session mgmt
1 parent f146918 commit 9ff16bb

File tree

3 files changed

+354
-17
lines changed

3 files changed

+354
-17
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,6 @@ venv.bak/
113113
# SCP Exports folder (Testing purpose)
114114
tests/exports/
115115
exports/
116+
117+
# VSCode
118+
.vscode/

src/badfish/main.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,24 +2136,28 @@ async def take_screenshot(self):
21362136
return True
21372137

21382138
async def delete_session(self):
2139-
if not self.session_id:
2140-
self.logger.debug("No session ID found, skipping session deletion")
2141-
return
2142-
2143-
headers = {"content-type": "application/json"}
2144-
_uri = "%s%s" % (self.host_uri, self.session_id)
2145-
21462139
try:
2147-
_response = await self.delete_request(_uri, headers=headers)
2148-
if _response.status in [200, 201]:
2149-
self.logger.debug(f"Session successfully deleted for {self.host}")
2150-
elif _response.status == 404:
2151-
self.logger.debug(f"Session not found (404) for {self.host}, may have been already deleted")
2152-
else:
2153-
self.logger.warning(f"Unexpected status {_response.status} when deleting session for {self.host}.")
2154-
except BadfishException as ex:
2155-
self.logger.warning(f"Failed to delete session for {self.host}: {ex}")
2156-
finally:
2140+
try:
2141+
if not self.session_id:
2142+
self.logger.debug("No session ID found, skipping session deletion")
2143+
return
2144+
headers = {"content-type": "application/json"}
2145+
_uri = "%s%s" % (self.host_uri, self.session_id)
2146+
try:
2147+
_response = await self.delete_request(_uri, headers=headers)
2148+
if _response.status in [200, 201]:
2149+
self.logger.debug(f"Session successfully deleted for {self.host}")
2150+
elif _response.status == 404:
2151+
self.logger.debug(f"Session not found (404) for {self.host}, may have been already deleted")
2152+
else:
2153+
self.logger.warning(f"Unexpected status {_response.status} when deleting session for {self.host}.")
2154+
except Exception as ex:
2155+
self.logger.warning(f"Failed to delete session for {self.host}: {ex}")
2156+
finally:
2157+
self.session_id = None
2158+
self.token = None
2159+
except Exception:
2160+
# Defensive: ensure no exception escapes
21572161
self.session_id = None
21582162
self.token = None
21592163

tests/test_context_manager.py

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import pytest
2+
import asyncio
3+
from unittest.mock import AsyncMock, patch, MagicMock
4+
from src.badfish.main import Badfish, BadfishException, badfish_factory
5+
6+
7+
class MockLogger:
8+
def __init__(self):
9+
self.debug_calls = []
10+
self.warning_calls = []
11+
self.info_calls = []
12+
self.error_calls = []
13+
14+
def debug(self, message):
15+
self.debug_calls.append(message)
16+
17+
def warning(self, message):
18+
self.warning_calls.append(message)
19+
20+
def info(self, message):
21+
self.info_calls.append(message)
22+
23+
def error(self, message):
24+
self.error_calls.append(message)
25+
26+
27+
MOCK_HOST = "test-host.example.com"
28+
MOCK_USERNAME = "test_user"
29+
MOCK_PASSWORD = "test_password"
30+
MOCK_RETRIES = 5
31+
32+
33+
class TestContextManager:
34+
"""Test cases for the async context manager methods."""
35+
36+
@pytest.mark.asyncio
37+
async def test_context_manager_successful_entry_exit(self):
38+
"""Test successful entry and exit of the context manager."""
39+
logger = MockLogger()
40+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
41+
42+
# Mock the init method
43+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
44+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
45+
async with badfish_instance as bf:
46+
assert bf is badfish_instance
47+
mock_init.assert_called_once()
48+
49+
mock_delete.assert_called_once()
50+
51+
@pytest.mark.asyncio
52+
async def test_context_manager_exception_handling(self):
53+
"""Test that exceptions are properly handled and logged."""
54+
logger = MockLogger()
55+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
56+
57+
# Mock the init method to raise an exception
58+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
59+
mock_init.side_effect = BadfishException("Test exception")
60+
61+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
62+
with pytest.raises(BadfishException):
63+
async with badfish_instance:
64+
pass
65+
66+
# When init fails, __aexit__ is not called because the exception is raised before entering the context
67+
mock_delete.assert_not_called()
68+
69+
@pytest.mark.asyncio
70+
async def test_context_manager_direct_method_calls(self):
71+
"""Test direct calls to __aenter__ and __aexit__ methods."""
72+
logger = MockLogger()
73+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
74+
75+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
76+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
77+
# Test direct __aenter__ call
78+
result = await badfish_instance.__aenter__()
79+
assert result is badfish_instance
80+
mock_init.assert_called_once()
81+
82+
# Test direct __aexit__ call
83+
await badfish_instance.__aexit__(None, None, None)
84+
mock_delete.assert_called_once()
85+
86+
@pytest.mark.asyncio
87+
async def test_context_manager_nested_usage(self):
88+
"""Test nested usage of the context manager."""
89+
logger = MockLogger()
90+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
91+
92+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
93+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
94+
# First context manager usage
95+
async with badfish_instance as bf1:
96+
assert bf1 is badfish_instance
97+
98+
# Nested context manager usage (should call init again)
99+
async with badfish_instance as bf2:
100+
assert bf2 is badfish_instance
101+
102+
# delete_session should be called twice (once for each context)
103+
assert mock_delete.call_count == 2
104+
# init should be called twice (once for each context entry)
105+
assert mock_init.call_count == 2
106+
107+
@pytest.mark.asyncio
108+
async def test_context_manager_integration_with_factory(self):
109+
"""Test context manager integration with badfish_factory."""
110+
logger = MockLogger()
111+
112+
with patch.object(Badfish, 'init', new_callable=AsyncMock) as mock_init:
113+
with patch.object(Badfish, 'delete_session', new_callable=AsyncMock) as mock_delete:
114+
async with await badfish_factory(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES) as badfish:
115+
assert isinstance(badfish, Badfish)
116+
assert badfish.host == MOCK_HOST
117+
assert badfish.username == MOCK_USERNAME
118+
assert badfish.password == MOCK_PASSWORD
119+
# badfish_factory calls init, then context manager calls it again
120+
assert mock_init.call_count == 2
121+
122+
mock_delete.assert_called_once()
123+
124+
@pytest.mark.asyncio
125+
async def test_context_manager_session_cleanup(self):
126+
"""Test that session cleanup happens even with exceptions."""
127+
logger = MockLogger()
128+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
129+
130+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
131+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
132+
try:
133+
async with badfish_instance:
134+
raise ValueError("Unexpected error")
135+
except ValueError:
136+
pass
137+
138+
# delete_session should still be called despite the exception
139+
mock_delete.assert_called_once()
140+
141+
@pytest.mark.asyncio
142+
async def test_context_manager_init_failure(self):
143+
"""Test context manager behavior when init fails."""
144+
logger = MockLogger()
145+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
146+
147+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
148+
mock_init.side_effect = BadfishException("Init failed")
149+
150+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
151+
with pytest.raises(BadfishException):
152+
async with badfish_instance:
153+
pass
154+
155+
# When init fails, __aexit__ is not called
156+
mock_delete.assert_not_called()
157+
158+
@pytest.mark.asyncio
159+
async def test_context_manager_delete_session_failure(self):
160+
"""Test context manager behavior when delete_session fails."""
161+
logger = MockLogger()
162+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
163+
164+
with patch.object(badfish_instance, 'init', new_callable=AsyncMock) as mock_init:
165+
with patch.object(badfish_instance, 'delete_session', new_callable=AsyncMock) as mock_delete:
166+
mock_delete.side_effect = BadfishException("Delete session failed")
167+
168+
# The exception should be re-raised from __aexit__
169+
with pytest.raises(BadfishException):
170+
async with badfish_instance:
171+
pass
172+
173+
174+
class TestDeleteSession:
175+
"""Test cases for the delete_session method."""
176+
177+
@pytest.mark.asyncio
178+
async def test_delete_session_success(self):
179+
"""Test successful session deletion."""
180+
logger = MockLogger()
181+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
182+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
183+
badfish_instance.token = "test_token"
184+
185+
# Mock the delete_request method
186+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
187+
# Mock response with status 200
188+
mock_response = MagicMock()
189+
mock_response.status = 200
190+
mock_delete_request.return_value = mock_response
191+
192+
await badfish_instance.delete_session()
193+
194+
# Verify delete_request was called with correct parameters
195+
mock_delete_request.assert_called_once()
196+
call_args = mock_delete_request.call_args
197+
assert call_args[0][0] == f"https://{MOCK_HOST}/redfish/v1/SessionService/Sessions/123"
198+
assert call_args[1]['headers'] == {"content-type": "application/json"}
199+
200+
# Verify session_id and token are cleared
201+
assert badfish_instance.session_id is None
202+
assert badfish_instance.token is None
203+
204+
# Verify success message was logged
205+
assert any("Session successfully deleted" in call for call in logger.debug_calls)
206+
207+
@pytest.mark.asyncio
208+
async def test_delete_session_404_status(self):
209+
"""Test session deletion with 404 status (session not found)."""
210+
logger = MockLogger()
211+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
212+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
213+
badfish_instance.token = "test_token"
214+
215+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
216+
# Mock response with status 404
217+
mock_response = MagicMock()
218+
mock_response.status = 404
219+
mock_delete_request.return_value = mock_response
220+
221+
await badfish_instance.delete_session()
222+
223+
# Verify session_id and token are still cleared
224+
assert badfish_instance.session_id is None
225+
assert badfish_instance.token is None
226+
227+
# Verify appropriate message was logged
228+
assert any("Session not found (404)" in call for call in logger.debug_calls)
229+
230+
@pytest.mark.asyncio
231+
async def test_delete_session_unexpected_status(self):
232+
"""Test session deletion with unexpected status code."""
233+
logger = MockLogger()
234+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
235+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
236+
badfish_instance.token = "test_token"
237+
238+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
239+
# Mock response with unexpected status
240+
mock_response = MagicMock()
241+
mock_response.status = 500
242+
mock_delete_request.return_value = mock_response
243+
244+
await badfish_instance.delete_session()
245+
246+
# Verify session_id and token are still cleared
247+
assert badfish_instance.session_id is None
248+
assert badfish_instance.token is None
249+
250+
# Verify warning was logged
251+
assert any("Unexpected status 500" in call for call in logger.warning_calls)
252+
253+
@pytest.mark.asyncio
254+
async def test_delete_session_exception_handling(self):
255+
"""Test session deletion when delete_request raises an exception."""
256+
logger = MockLogger()
257+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
258+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
259+
badfish_instance.token = "test_token"
260+
261+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
262+
mock_delete_request.side_effect = BadfishException("Network error")
263+
264+
await badfish_instance.delete_session()
265+
266+
# Verify session_id and token are still cleared despite the exception
267+
assert badfish_instance.session_id is None
268+
assert badfish_instance.token is None
269+
270+
# Verify exception was logged as warning
271+
assert any("Failed to delete session" in call for call in logger.warning_calls)
272+
assert any("Network error" in call for call in logger.warning_calls)
273+
274+
@pytest.mark.asyncio
275+
async def test_delete_session_no_session_id(self):
276+
"""Test delete_session when no session_id is set."""
277+
logger = MockLogger()
278+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
279+
badfish_instance.session_id = None
280+
badfish_instance.token = "test_token"
281+
282+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
283+
await badfish_instance.delete_session()
284+
285+
# delete_request should not be called
286+
mock_delete_request.assert_not_called()
287+
288+
# token should still be cleared
289+
assert badfish_instance.token is None
290+
291+
# Verify appropriate message was logged
292+
assert any("No session ID found" in call for call in logger.debug_calls)
293+
294+
@pytest.mark.asyncio
295+
async def test_delete_session_cleanup_always_executes(self):
296+
"""Test that cleanup (clearing session_id and token) always executes."""
297+
logger = MockLogger()
298+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
299+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
300+
badfish_instance.token = "test_token"
301+
302+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
303+
# Mock response to raise an exception
304+
mock_delete_request.side_effect = Exception("Unexpected error")
305+
306+
# The method should not raise the exception due to try/finally
307+
await badfish_instance.delete_session()
308+
309+
# Verify session_id and token are still cleared despite the exception
310+
assert badfish_instance.session_id is None
311+
assert badfish_instance.token is None
312+
313+
@pytest.mark.asyncio
314+
async def test_delete_session_other_exception(self):
315+
"""Test delete_session with non-BadfishException."""
316+
logger = MockLogger()
317+
badfish_instance = Badfish(MOCK_HOST, MOCK_USERNAME, MOCK_PASSWORD, logger, MOCK_RETRIES)
318+
badfish_instance.session_id = "/redfish/v1/SessionService/Sessions/123"
319+
badfish_instance.token = "test_token"
320+
321+
with patch.object(badfish_instance, 'delete_request', new_callable=AsyncMock) as mock_delete_request:
322+
# Mock response to raise a different exception
323+
mock_delete_request.side_effect = ValueError("Unexpected error")
324+
325+
# The method should not raise the exception due to try/finally
326+
await badfish_instance.delete_session()
327+
328+
# Verify session_id and token are still cleared
329+
assert badfish_instance.session_id is None
330+
assert badfish_instance.token is None

0 commit comments

Comments
 (0)