Skip to content

Commit a8f3a3a

Browse files
Merge pull request #481 from martin-belanger/more-unit-tests
test: expand unit-test coverage across four test files
2 parents cd16eba + 351d000 commit a8f3a3a

5 files changed

Lines changed: 240 additions & 5 deletions

File tree

coverage.sh.in

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -681,21 +681,22 @@ sudo chown -R "${PRIMARY_USR}":"${PRIMARY_GRP}" subprojects/nvme-cli/libnvme/lib
681681
# Run unit tests
682682
TEST_DIR=$( realpath ../test )
683683
run_unit_test ${TEST_DIR}/test-avahi.py
684-
run_unit_test ${TEST_DIR}/test-avahi.py
685684
run_unit_test ${TEST_DIR}/test-config.py
686685
run_unit_test ${TEST_DIR}/test-controller.py
686+
run_unit_test ${TEST_DIR}/test-defs.py
687687
run_unit_test ${TEST_DIR}/test-gtimer.py
688+
run_unit_test ${TEST_DIR}/test-gutil.py
688689
run_unit_test ${TEST_DIR}/test-iputil.py
689690
run_unit_test ${TEST_DIR}/test-log.py
690-
run_unit_test ${TEST_DIR}/test-nbft.py
691691
run_unit_test ${TEST_DIR}/test-nbft_conf.py
692+
run_unit_test ${TEST_DIR}/test-nbft.py
692693
run_unit_test sudo ${TEST_DIR}/test-nvme_options.py # Test both with super user...
693694
run_unit_test ${TEST_DIR}/test-nvme_options.py # ... and with regular user
694695
run_unit_test ${TEST_DIR}/test-service.py
696+
run_unit_test ${TEST_DIR}/test-stasadm.py
697+
run_unit_test ${TEST_DIR}/test-stas.py
695698
run_unit_test ${TEST_DIR}/test-timeparse.py
696699
run_unit_test ${TEST_DIR}/test-transport_id.py
697-
run_unit_test ${TEST_DIR}/test-defs.py
698-
run_unit_test ${TEST_DIR}/test-gutil.py
699700
run_unit_test ${TEST_DIR}/test-udev.py
700701
run_unit_test ${TEST_DIR}/test-version.py
701702

test/test-config.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,5 +264,93 @@ def test_config_missing_file(self):
264264
self.assertIsNone(system_conf.hostsymname)
265265

266266

267+
class TestParseController(unittest.TestCase):
268+
'''Unit tests for conf._parse_controller() — a pure function.'''
269+
270+
def test_empty_string_returns_empty_dict(self):
271+
self.assertEqual(conf._parse_controller(''), {})
272+
273+
def test_malformed_token_with_no_equals_is_silently_skipped(self):
274+
# Token without '=' causes ValueError in unpacking → silently ignored
275+
result = conf._parse_controller('noequalsign')
276+
self.assertEqual(result, {})
277+
278+
def test_token_with_extra_equals_is_silently_skipped(self):
279+
# 'key=val=extra' splits into 3 parts → ValueError → silently ignored
280+
result = conf._parse_controller('key=val=extra')
281+
self.assertEqual(result, {})
282+
283+
def test_mixed_valid_and_malformed_tokens(self):
284+
result = conf._parse_controller('transport=tcp;noequalsign;traddr=10.10.10.10')
285+
self.assertEqual(result, {'transport': 'tcp', 'traddr': '10.10.10.10'})
286+
287+
288+
class TestSvcConfEdgeCases(unittest.TestCase):
289+
'''Edge-case tests for SvcConf validation: out-of-range values, invalid
290+
sections/options. These tests rely on the SvcConf singleton having been
291+
initialised with a default_conf (which happens in StasProcessConfUnitTest),
292+
so they are defined after that class.
293+
'''
294+
295+
FNAME_OOR = '/tmp/stas-test-svc-oor.conf'
296+
FNAME_BADSEC = '/tmp/stas-test-svc-badsec.conf'
297+
FNAME_BADOPT = '/tmp/stas-test-svc-badopt.conf'
298+
FNAME_VALID = '/tmp/stas-test-svc-valid.conf'
299+
300+
@classmethod
301+
def setUpClass(cls):
302+
with open(cls.FNAME_OOR, 'w') as f:
303+
f.write('[Global]\nqueue-size=5\n') # 5 is below the valid range [16, 1024]
304+
with open(cls.FNAME_BADSEC, 'w') as f:
305+
f.write('[BadSection]\nfoo=bar\n')
306+
with open(cls.FNAME_BADOPT, 'w') as f:
307+
f.write('[Global]\nbad-option=something\n')
308+
with open(cls.FNAME_VALID, 'w') as f:
309+
f.write('[Global]\nip-family=ipv4+ipv6\n')
310+
311+
@classmethod
312+
def tearDownClass(cls):
313+
for fname in (cls.FNAME_OOR, cls.FNAME_BADSEC, cls.FNAME_BADOPT, cls.FNAME_VALID):
314+
if os.path.exists(fname):
315+
os.remove(fname)
316+
317+
def setUp(self):
318+
conf.SvcConf().set_conf_file(self.FNAME_VALID)
319+
320+
def test_queue_size_out_of_range_falls_back_to_none(self):
321+
conf.SvcConf().set_conf_file(self.FNAME_OOR)
322+
self.assertIsNone(conf.SvcConf().queue_size)
323+
324+
def test_invalid_section_logs_error(self):
325+
with self.assertLogs(level='ERROR'):
326+
conf.SvcConf().set_conf_file(self.FNAME_BADSEC)
327+
328+
def test_invalid_option_in_valid_section_logs_error(self):
329+
with self.assertLogs(level='ERROR'):
330+
conf.SvcConf().set_conf_file(self.FNAME_BADOPT)
331+
332+
333+
class TestSysConfNqnTooLong(unittest.TestCase):
334+
'''Tests for SysConf NQN length validation (> 223 chars → sys.exit).'''
335+
336+
FNAME = '/tmp/stas-test-long-nqn.conf'
337+
338+
@classmethod
339+
def setUpClass(cls):
340+
long_nqn = 'nqn.' + 'a' * 220 # 224 chars — exceeds 223-char NVMe spec limit
341+
with open(cls.FNAME, 'w') as f:
342+
f.write(f'[Host]\nnqn={long_nqn}\n')
343+
344+
@classmethod
345+
def tearDownClass(cls):
346+
if os.path.exists(cls.FNAME):
347+
os.remove(cls.FNAME)
348+
349+
def test_hostnqn_too_long_causes_exit(self):
350+
system_conf = conf.SysConf()
351+
system_conf.set_conf_file(self.FNAME)
352+
self.assertRaises(SystemExit, lambda: system_conf.hostnqn)
353+
354+
267355
if __name__ == '__main__':
268356
unittest.main()

test/test-gtimer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ def test_clear(self):
3838
self.assertEqual(tmr.time_remaining(), 0)
3939
self.assertEqual(str(tmr), '1.0s [0s]')
4040

41+
def test_callback_source_remove_clears_source(self):
42+
from gi.repository import GLib
43+
44+
tmr = gutil.GTimer(interval_sec=1, user_cback=lambda: GLib.SOURCE_REMOVE)
45+
tmr.start()
46+
self.assertIsNotNone(tmr._source)
47+
result = tmr._callback()
48+
self.assertEqual(result, GLib.SOURCE_REMOVE)
49+
# _callback() must clear _source when the user callback returns SOURCE_REMOVE
50+
self.assertIsNone(tmr._source)
51+
52+
def test_restart_running_timer_reschedules_in_place(self):
53+
from gi.repository import GLib
54+
55+
tmr = gutil.GTimer(interval_sec=10, user_cback=lambda: GLib.SOURCE_REMOVE)
56+
tmr.start()
57+
self.assertIsNotNone(tmr._source)
58+
source_before = tmr._source
59+
# start() on an already-running timer must reuse the existing GLib source
60+
# (via set_ready_time) rather than creating a new one.
61+
tmr.start()
62+
self.assertIs(tmr._source, source_before)
63+
tmr.kill()
64+
4165

4266
if __name__ == '__main__':
4367
unittest.main()

test/test-gutil.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/python3
2+
import os
23
import unittest
3-
from staslib import gutil
4+
from staslib import conf, gutil, trid
5+
6+
SUBSYSNQN = 'nqn.1988-11.com.dell:SFSS:2:20220208134025e8'
7+
HOSTNQN = 'nqn.2014-08.org.nvmexpress:uuid:01234567-0123-0123-0123-0123456789ab'
48

59

610
class GutilUnitTest(unittest.TestCase):
@@ -45,5 +49,78 @@ def test_Deferred(self):
4549
self.assertFalse(d.is_scheduled())
4650

4751

52+
# ==============================================================================
53+
class TestNameResolver(unittest.TestCase):
54+
'''Unit tests for NameResolver.resolve_ctrl_async() — synchronous paths only.
55+
56+
When traddr is already a valid IP address the resolver skips the async DNS
57+
lookup and calls the callback immediately from within resolve_ctrl_async().
58+
Non-TCP/RDMA transports also bypass DNS. Only those two paths are exercised
59+
here; async DNS resolution requires a live network and is not unit-testable.
60+
'''
61+
62+
FNAME_BOTH = '/tmp/stas-test-nr-both.conf'
63+
FNAME_IPV6 = '/tmp/stas-test-nr-ipv6.conf'
64+
65+
@classmethod
66+
def setUpClass(cls):
67+
with open(cls.FNAME_BOTH, 'w') as f:
68+
f.write('[Global]\nip-family=ipv4+ipv6\n')
69+
with open(cls.FNAME_IPV6, 'w') as f:
70+
f.write('[Global]\nip-family=ipv6\n')
71+
conf.SvcConf().set_conf_file(cls.FNAME_BOTH)
72+
73+
@classmethod
74+
def tearDownClass(cls):
75+
for fname in (cls.FNAME_BOTH, cls.FNAME_IPV6):
76+
if os.path.exists(fname):
77+
os.remove(fname)
78+
79+
def setUp(self):
80+
# Reset to the "both families" config before every test so that a test
81+
# that switches to FNAME_IPV6 does not leak into the next one.
82+
conf.SvcConf().set_conf_file(self.FNAME_BOTH)
83+
84+
def _make_tid(self, transport, traddr):
85+
return trid.TID({'transport': transport, 'traddr': traddr, 'subsysnqn': SUBSYSNQN, 'host-nqn': HOSTNQN})
86+
87+
def test_empty_list_calls_callback_immediately(self):
88+
resolver = gutil.NameResolver()
89+
result = []
90+
resolver.resolve_ctrl_async(None, [], lambda ctrls: result.extend(ctrls))
91+
self.assertEqual(result, [])
92+
93+
def test_ipv4_address_resolves_synchronously(self):
94+
resolver = gutil.NameResolver()
95+
t = self._make_tid('tcp', '10.10.10.10')
96+
result = []
97+
resolver.resolve_ctrl_async(None, [t], lambda ctrls: result.extend(ctrls))
98+
self.assertEqual(len(result), 1)
99+
self.assertEqual(result[0].traddr, '10.10.10.10')
100+
101+
def test_ipv6_address_resolves_synchronously(self):
102+
resolver = gutil.NameResolver()
103+
t = self._make_tid('tcp', '::1')
104+
result = []
105+
resolver.resolve_ctrl_async(None, [t], lambda ctrls: result.extend(ctrls))
106+
self.assertEqual(len(result), 1)
107+
self.assertEqual(result[0].traddr, '::1')
108+
109+
def test_fc_transport_passes_through_without_dns(self):
110+
resolver = gutil.NameResolver()
111+
t = self._make_tid('fc', 'nn-0x1000000044001123:pn-0x2000000055001123')
112+
result = []
113+
resolver.resolve_ctrl_async(None, [t], lambda ctrls: result.extend(ctrls))
114+
self.assertEqual(len(result), 1)
115+
116+
def test_ipv4_excluded_when_only_ipv6_allowed(self):
117+
conf.SvcConf().set_conf_file(self.FNAME_IPV6)
118+
resolver = gutil.NameResolver()
119+
t = self._make_tid('tcp', '10.10.10.10')
120+
result = []
121+
resolver.resolve_ctrl_async(None, [t], lambda ctrls: result.extend(ctrls))
122+
self.assertEqual(result, [])
123+
124+
48125
if __name__ == '__main__':
49126
unittest.main()

test/test-stas.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#!/usr/bin/python3
22
import os
33
import unittest
4+
from unittest.mock import patch
45
from staslib import conf, stas, trid
6+
from pyfakefs.fake_filesystem_unittest import TestCase as FakeTestCase
57

68
HOSTNQN = 'nqn.2014-08.org.nvmexpress:uuid:01234567-0123-0123-0123-0123456789ab'
79
SUBSYSNQN = 'nqn.1988-11.com.dell:SFSS:2:20220208134025e8'
@@ -258,5 +260,48 @@ def test_unknown_transport_always_removed(self):
258260
self.assertEqual(stas.remove_invalid_addresses([t]), [])
259261

260262

263+
# ==============================================================================
264+
class TestLoadIdl(unittest.TestCase):
265+
'''Unit tests for stas.load_idl().'''
266+
267+
def test_stafd_idl_returns_content(self):
268+
content = stas.load_idl('stafd.idl')
269+
self.assertIsInstance(content, str)
270+
self.assertGreater(len(content), 0)
271+
272+
def test_stacd_idl_returns_content(self):
273+
content = stas.load_idl('stacd.idl')
274+
self.assertIsInstance(content, str)
275+
self.assertGreater(len(content), 0)
276+
277+
def test_nonexistent_idl_returns_empty_string(self):
278+
content = stas.load_idl('nonexistent.idl')
279+
self.assertEqual(content, '')
280+
281+
282+
# ==============================================================================
283+
class TestCheckIfAllowedToContinue(FakeTestCase):
284+
'''Unit tests for stas.check_if_allowed_to_continue() using a fake filesystem.'''
285+
286+
def setUp(self):
287+
self.setUpPyfakefs()
288+
289+
def test_exits_when_not_root(self):
290+
with patch('os.geteuid', return_value=1000):
291+
with self.assertRaises(SystemExit):
292+
stas.check_if_allowed_to_continue()
293+
294+
def test_exits_when_nvme_fabrics_missing(self):
295+
# /dev/nvme-fabrics is absent in the fake filesystem by default
296+
with patch('os.geteuid', return_value=0):
297+
with self.assertRaises(SystemExit):
298+
stas.check_if_allowed_to_continue()
299+
300+
def test_returns_normally_when_root_and_nvme_fabrics_present(self):
301+
self.fs.create_file('/dev/nvme-fabrics')
302+
with patch('os.geteuid', return_value=0):
303+
stas.check_if_allowed_to_continue() # Must not raise
304+
305+
261306
if __name__ == '__main__':
262307
unittest.main()

0 commit comments

Comments
 (0)