diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92bb8078..f576aa10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -539,6 +539,11 @@ jobs: fi "$PYTHON" -m pip install ..[yubikey] + - name: Run unit tests + run: | + "$PYTHON" -m pip install pytest + "$PYTHON" -m pytest ../tests/ -v + - name: Install PyInstaller if: matrix.goal == 'build' run: | diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..5ee64771 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..cabb3633 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,63 @@ +"""Pytest configuration and shared fixtures for GAM tests. + +Path setup: GAM's modules use bare 'from util.X import ...' which requires the +gam package directory on sys.path. We add it in a fixture (not at module level) +to avoid shadowing Python's stdlib 'cmd' module during pytest configuration. +""" + +import os +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _gam_path_and_globals(): + """Add GAM's package dir to sys.path and initialize minimal global state. + + Done in a fixture rather than at module level to avoid polluting sys.path + during pytest's own import of stdlib modules (e.g. cmd, which would collide + with gam/cmd/). + """ + gam_pkg_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam') + gam_pkg_dir = os.path.normpath(gam_pkg_dir) + + # Temporarily prepend — will be cleaned up after test + inserted = False + if gam_pkg_dir not in sys.path: + sys.path.insert(0, gam_pkg_dir) + inserted = True + + # Also need src/ on path for 'gam' package imports + src_dir = os.path.join(os.path.dirname(__file__), '..', 'src') + src_dir = os.path.normpath(src_dir) + src_inserted = False + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + src_inserted = True + + from gamlib import glcfg as GC + from gamlib import glglobals as GM + + # Ensure Values dict exists with minimal defaults + if not GC.Values: + GC.Values = {} + GC.Values.setdefault(GC.DOMAIN, 'example.com') + GC.Values.setdefault(GC.CUSTOMER_ID, 'C00000000') + GC.Values.setdefault(GC.TIMEZONE, 'UTC') + GC.Values.setdefault(GC.SHOW_COUNTS_MIN, 0) + + # Ensure Globals dict exists + if not GM.Globals: + GM.Globals = {} + GM.Globals.setdefault(GM.STDOUT, None) + GM.Globals.setdefault(GM.STDERR, None) + GM.Globals.setdefault(GM.SYSEXITRC, 0) + + yield + + # Clean up sys.path to avoid side effects between test files + if inserted and gam_pkg_dir in sys.path: + sys.path.remove(gam_pkg_dir) + if src_inserted and src_dir in sys.path: + sys.path.remove(src_dir) diff --git a/tests/test_args.py b/tests/test_args.py new file mode 100644 index 00000000..a19afa50 --- /dev/null +++ b/tests/test_args.py @@ -0,0 +1,181 @@ +"""Unit tests for gam.util.args — argument parsing and formatting functions.""" + +import pytest +import arrow + + +class TestCourseScopes: + """Test course ID and alias scope manipulation.""" + + def test_add_course_id_scope_plain(self): + from gam.util.args import addCourseIdScope + assert addCourseIdScope('12345') == '12345' # numeric, no prefix + + def test_add_course_id_scope_name(self): + from gam.util.args import addCourseIdScope + assert addCourseIdScope('my-course') == 'd:my-course' + + def test_add_course_id_scope_already_prefixed(self): + from gam.util.args import addCourseIdScope + assert addCourseIdScope('d:my-course') == 'd:my-course' + assert addCourseIdScope('p:my-course') == 'p:my-course' + + def test_remove_course_id_scope(self): + from gam.util.args import removeCourseIdScope + assert removeCourseIdScope('d:my-course') == 'my-course' + + def test_remove_course_id_scope_no_prefix(self): + from gam.util.args import removeCourseIdScope + assert removeCourseIdScope('12345') == '12345' + + def test_add_course_alias_scope(self): + from gam.util.args import addCourseAliasScope + assert addCourseAliasScope('my-alias') == 'd:my-alias' + + def test_add_course_alias_scope_already_prefixed(self): + from gam.util.args import addCourseAliasScope + assert addCourseAliasScope('d:my-alias') == 'd:my-alias' + assert addCourseAliasScope('p:my-alias') == 'p:my-alias' + + def test_remove_course_alias_scope(self): + from gam.util.args import removeCourseAliasScope + assert removeCourseAliasScope('d:my-alias') == 'my-alias' + + def test_remove_course_alias_scope_no_prefix(self): + from gam.util.args import removeCourseAliasScope + assert removeCourseAliasScope('my-alias') == 'my-alias' + + +class TestEmailAddressParsing: + """Test email address parsing and normalization.""" + + def test_get_email_domain(self): + from gam.util.args import getEmailAddressDomain + assert getEmailAddressDomain('user@domain.com') == 'domain.com' + + def test_get_email_domain_no_at(self): + from gam.util.args import getEmailAddressDomain + # Falls back to GC.Values[GC.DOMAIN] which is 'example.com' in conftest + assert getEmailAddressDomain('justuser') == 'example.com' + + def test_get_email_domain_case(self): + from gam.util.args import getEmailAddressDomain + assert getEmailAddressDomain('user@DOMAIN.COM') == 'domain.com' + + def test_get_email_username(self): + from gam.util.args import getEmailAddressUsername + assert getEmailAddressUsername('user@domain.com') == 'user' + + def test_get_email_username_no_at(self): + from gam.util.args import getEmailAddressUsername + assert getEmailAddressUsername('justuser') == 'justuser' + + def test_split_email(self): + from gam.util.args import splitEmailAddress + assert splitEmailAddress('User@Domain.COM') == ('user', 'domain.com') + + def test_split_email_no_at(self): + from gam.util.args import splitEmailAddress + assert splitEmailAddress('JustUser') == ('justuser', 'example.com') + + def test_normalize_uid(self): + from gam.util.args import normalizeEmailAddressOrUID + # uid: prefix should be stripped + assert normalizeEmailAddressOrUID('uid:12345') == '12345' + + def test_normalize_email_lowercased(self): + from gam.util.args import normalizeEmailAddressOrUID + result = normalizeEmailAddressOrUID('User@Domain.COM') + assert result == 'user@domain.com' + + def test_normalize_email_no_lower(self): + from gam.util.args import normalizeEmailAddressOrUID + result = normalizeEmailAddressOrUID('User@Domain.COM', noLower=True) + assert result == 'User@Domain.COM' + + def test_normalize_bare_username_gets_domain(self): + from gam.util.args import normalizeEmailAddressOrUID + result = normalizeEmailAddressOrUID('admin') + assert result == 'admin@example.com' + + +class TestFormatting: + """Test formatting helper functions.""" + + def test_format_milliseconds(self): + from gam.util.args import formatMilliSeconds + assert formatMilliSeconds(0) == '00:00:00' + assert formatMilliSeconds(1000) == '00:00:01' + assert formatMilliSeconds(61000) == '00:01:01' + assert formatMilliSeconds(3661000) == '01:01:01' + + def test_format_http_error(self): + from gam.util.args import formatHTTPError + result = formatHTTPError(404, 'Not Found', 'Resource missing') + assert result == '404: Not Found - Resource missing' + + def test_format_max_message_bytes_raw(self): + from gam.util.args import formatMaxMessageBytes + assert formatMaxMessageBytes(500, 1024, 1048576) == 500 + + def test_format_max_message_bytes_kilobytes(self): + from gam.util.args import formatMaxMessageBytes + assert formatMaxMessageBytes(2048, 1024, 1048576) == '2K' + + def test_format_max_message_bytes_megabytes(self): + from gam.util.args import formatMaxMessageBytes + assert formatMaxMessageBytes(5242880, 1024, 1048576) == '5M' + + def test_format_file_size_zero(self): + from gam.util.args import formatFileSize + assert formatFileSize(0) == '0 KB' + + def test_format_file_size_small(self): + from gam.util.args import formatFileSize + assert formatFileSize(500) == '1 KB' + + +class TestTimestamps: + """Test timestamp formatting.""" + + def test_iso_format_timestamp(self): + from gam.util.output import ISOformatTimeStamp + ts = arrow.get('2024-01-15T10:30:00+00:00') + result = ISOformatTimeStamp(ts) + assert '2024-01-15' in result + assert '10:30:00' in result + + def test_current_iso_format_returns_string(self): + from gam.util.output import currentISOformatTimeStamp + result = currentISOformatTimeStamp() + assert isinstance(result, str) + assert 'T' in result # ISO format contains T separator + + +class TestDeltaParsing: + """Test time delta parsing functions.""" + + def test_get_delta_invalid_returns_none(self): + from gam.util.args import getDelta, DELTA_DATE_PATTERN + result = getDelta('not-a-delta', DELTA_DATE_PATTERN) + assert result is None + + def test_get_delta_date(self): + """Requires GM.DATETIME_NOW to be initialized.""" + import arrow as _arrow + from gamlib import glglobals as GM + GM.Globals[GM.DATETIME_NOW] = _arrow.now() + + from gam.util.args import getDeltaDate + result = getDeltaDate('-30d') + assert result is not None + + def test_get_delta_time(self): + """Requires GM.DATETIME_NOW to be initialized.""" + import arrow as _arrow + from gamlib import glglobals as GM + GM.Globals[GM.DATETIME_NOW] = _arrow.now() + + from gam.util.args import getDeltaTime + result = getDeltaTime('-1h') + assert result is not None diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 00000000..65d104a6 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,62 @@ +"""Structural tests — verify all modules import without errors.""" + +import importlib +import os +import sys + +import pytest + + +class TestAllModulesImport: + """Every module in gam/ should import without errors.""" + + def test_gam_package_imports(self): + """The top-level gam package should import without circular import errors.""" + import gam + assert hasattr(gam, 'ProcessGAMCommand') + + def test_util_modules_import(self): + """All util/ modules should import individually without errors.""" + util_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam', 'util') + util_dir = os.path.normpath(util_dir) + + failures = [] + for f in sorted(os.listdir(util_dir)): + if not f.endswith('.py') or f == '__init__.py': + continue + mod_name = f'gam.util.{f[:-3]}' + try: + importlib.import_module(mod_name) + except Exception as e: + failures.append(f'{mod_name}: {e}') + + assert not failures, "Failed to import:\n" + "\n".join(failures) + + def test_no_local_imports_in_util(self): + """util/ modules should not have function-scope imports from other util/ modules. + + The one known exception is api.py:getSaUser (documented api<->uid cycle). + """ + import re + util_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam', 'util') + util_dir = os.path.normpath(util_dir) + + KNOWN_EXCEPTIONS = { + ('api.py', 'from util.uid import convertUIDtoEmailAddress'), + } + + violations = [] + for f in sorted(os.listdir(util_dir)): + if not f.endswith('.py') or f == '__init__.py': + continue + with open(os.path.join(util_dir, f)) as fh: + for i, line in enumerate(fh, 1): + if re.match(r'^\s+from (util|gam\.util)\.\w+ import', line): + text = line.strip().split('#')[0].strip() + if (f, text) not in KNOWN_EXCEPTIONS: + violations.append(f'{f}:{i}: {line.strip()}') + + assert not violations, ( + f"Found {len(violations)} unexpected local import(s) in util/:\n" + + "\n".join(violations) + ) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..d4332e57 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,99 @@ +"""Unit tests for gam.util.output — output formatting and utility functions.""" + +import pytest + + +class TestStripControlChars: + """Test control character stripping.""" + + def test_strips_null(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('hello\x00world') == 'helloworld' + + def test_strips_cr(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('hello\rworld') == 'helloworld' + + def test_strips_nl(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('hello\nworld') == 'helloworld' + + def test_strips_multiple(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('\x00he\rll\no') == 'hello' + + def test_clean_string_unchanged(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('hello world') == 'hello world' + + def test_empty_string(self): + from gam.util.output import _stripControlCharsFromName + assert _stripControlCharsFromName('') == '' + + +class TestCurrentCount: + """Test count display formatting.""" + + def test_current_count_with_items(self): + from gam.util.output import currentCount + result = currentCount(3, 10) + assert result == ' (3/10)' + + def test_current_count_nl(self): + from gam.util.output import currentCountNL + result = currentCountNL(3, 10) + assert result == ' (3/10)\n' + + +class TestFormatKeyValueList: + """Test key-value list formatting. + + formatKeyValueList processes pairs: [key1, val1, key2, val2, ...] + Each key gets ': ' and its value appended. + """ + + def test_single_pair(self): + from gam.util.output import formatKeyValueList + result = formatKeyValueList('', ['Name', 'Alice'], '') + assert result == 'Name: Alice' + + def test_multiple_pairs(self): + from gam.util.output import formatKeyValueList + result = formatKeyValueList('', ['Name', 'Alice', 'Age', 30], '') + assert 'Name: Alice' in result + assert 'Age: 30' in result + + def test_with_prefix_suffix(self): + from gam.util.output import formatKeyValueList + result = formatKeyValueList(' ', ['key', 'val'], '\n') + assert result == ' key: val\n' + + def test_none_value(self): + from gam.util.output import formatKeyValueList + result = formatKeyValueList('', ['key', None], '') + assert result == 'key:' + + def test_empty_list(self): + from gam.util.output import formatKeyValueList + result = formatKeyValueList('pre', [], 'suf') + assert result == 'presuf' + + +class TestColoredText: + """Test colored text creation.""" + + def test_create_colored_text_returns_string(self): + from gam.util.output import createColoredText + result = createColoredText('hello', 'red') + assert isinstance(result, str) + assert 'hello' in result + + def test_create_red_text(self): + from gam.util.output import createRedText + result = createRedText('error') + assert 'error' in result + + def test_create_green_text(self): + from gam.util.output import createGreenText + result = createGreenText('success') + assert 'success' in result