diff --git a/sbs/cli.py b/sbs/cli.py index 5c6153d..8f491e0 100644 --- a/sbs/cli.py +++ b/sbs/cli.py @@ -28,7 +28,10 @@ def load_map(fname: str) -> dict: mapping = {} with open(fname, "r") as f: for line in f.readlines(): - username, name = line.replace("\n", "").split("=") + line = line.replace("\n", "") + if not line or "=" not in line: + continue + username, name = line.split("=", 1) mapping[username] = name return mapping @@ -45,7 +48,7 @@ class _TextOrFileAction(argparse.Action): parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: typing.Any, - option_string: str, + option_strings: str, ): """ Handles loading content from a file or passing the string value @@ -54,7 +57,7 @@ class _TextOrFileAction(argparse.Action): :param parser: Argument parser in use :param namespace: The namespace associated with the parser :param values: The value provided via the argument - :param option_string: The option used to set the value + :param option_strings: The option used to set the value """ if isinstance(values, str): if values.startswith("@"): @@ -68,35 +71,45 @@ class _TextOrFileAction(argparse.Action): setattr(namespace, self.dest, values) else: raise argparse.ArgumentError( - f"{option_string} must be a path to a file starting with @ or text" + option_strings, + f"{option_strings} must be a path to a file starting with @ or text", ) -def _common_switches(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: +def _make_parser() -> argparse.ArgumentParser: # pragma: no cover """ - Append common switches to subparsers. + Creates and populates the parser for the command line. Uses an internal function + for common switches. - :param parser: Parser to attach common switches + This function isn't in unittests as it's overwhelmingly standard library boilerplate. """ - parser.add_argument("mapping", help="Mapping file for Jira Account=Name") - parser.add_argument("output", help="The file to write") - parser.add_argument( - "-p", "--prompt", help="Text or @file prompt to use", action=_TextOrFileAction - ) - parser.add_argument( - "-i", - "--ignore-unknowns", - default=False, - action="store_true", - help="Ignores issues that don't have a known assignee", - ) - return parser + # -- Internal function for attaching common switches to subparsers + def _common_switches(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """ + Append common switches to subparsers. + + :param parser: Parser to attach common switches + """ + parser.add_argument("mapping", help="Mapping file for Jira Account=Name") + parser.add_argument("output", help="The file to write") + parser.add_argument( + "-p", + "--prompt", + help="Text or @file prompt to use", + action=_TextOrFileAction, + ) + parser.add_argument( + "-i", + "--ignore-unknowns", + default=False, + action="store_true", + help="Ignores issues that don't have a known assignee", + ) + return parser + + # -- -def main(): - """ - CLI entry point. - """ parser = argparse.ArgumentParser( description="Turns Jira output into a Gemini prompt to generate status", ) @@ -123,6 +136,14 @@ def main(): csv_parser.add_argument("csvfile", help="Path to the CSV file to read") csv_parser = _common_switches(csv_parser) + return parser + + +def main(): # pragma: no cover + """ + CLI entry point. + """ + parser = _make_parser() # Parse the args so we can do the work args = parser.parse_args() # But if we don't have any input show the help and exit diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..188fa56 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,198 @@ +# This file is part of sbs +# +# Copyright (C) 2025 Steve Milner +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Generated by Gemini 2.5 Flash + +import pytest +import argparse +import typing +from unittest.mock import MagicMock, patch, mock_open + +from sbs.cli import _TextOrFileAction, load_map + + +@pytest.fixture +def text_or_file_action_instance(): + """ + Provides an instance of _TextOrFileAction with a predefined 'dest'. + """ + # Initialize with a dummy option_strings and dest for testing purposes + return _TextOrFileAction( + option_strings=["-t", "--text-or-file"], dest="content_param" + ) + + +@pytest.fixture +def mock_parser(): + """ + Provides a mock argparse.ArgumentParser. + """ + return MagicMock(spec=argparse.ArgumentParser) + + +@pytest.fixture +def mock_namespace(): + """ + Provides a mock argparse.Namespace to simulate argument storage. + """ + return MagicMock(spec=argparse.Namespace) + + +def test_direct_string_input(text_or_file_action_instance, mock_parser, mock_namespace): + """ + Tests that a direct string value is correctly assigned to the namespace. + """ + value = "This is a direct string input." + option_string = "--text-or-file" + + text_or_file_action_instance(mock_parser, mock_namespace, value, option_string) + + # Assert that the namespace attribute 'content_param' was set to the direct string value + assert mock_namespace.content_param == value + # Ensure no file operations were attempted + with pytest.raises(AttributeError): + mock_open.assert_not_called() + + +def test_file_input_without_trailing_newline( + text_or_file_action_instance, mock_parser, mock_namespace +): + """ + Tests that content from a file without a trailing newline is correctly read and assigned. + """ + file_content = "Content from a test file." + value = "@path/to/test_file.txt" + option_string = "--text-or-file" + + # Use mock_open to simulate reading from a file + with patch("builtins.open", mock_open(read_data=file_content)) as mock_file: + text_or_file_action_instance(mock_parser, mock_namespace, value, option_string) + + # Assert that 'open' was called with the correct path (without '@') + mock_file.assert_called_once_with("path/to/test_file.txt") + # Assert that the namespace attribute was set with the file's content + assert mock_namespace.content_param == file_content + + +def test_file_input_with_trailing_newline( + text_or_file_action_instance, mock_parser, mock_namespace +): + """ + Tests that content from a file with a trailing newline is correctly read and the newline is stripped. + """ + file_content_with_newline = "Content with a newline.\n" + expected_content = "Content with a newline." + value = "@path/to/test_file_with_newline.txt" + option_string = "--text-or-file" + + with patch( + "builtins.open", mock_open(read_data=file_content_with_newline) + ) as mock_file: + text_or_file_action_instance(mock_parser, mock_namespace, value, option_string) + + mock_file.assert_called_once_with("path/to/test_file_with_newline.txt") + # Assert that the newline was stripped from the content + assert mock_namespace.content_param == expected_content + + +def test_file_input_empty_file( + text_or_file_action_instance, mock_parser, mock_namespace +): + """ + Tests handling of an empty file. + """ + file_content = "" + value = "@path/to/empty_file.txt" + option_string = "--text-or-file" + + with patch("builtins.open", mock_open(read_data=file_content)) as mock_file: + text_or_file_action_instance(mock_parser, mock_namespace, value, option_string) + + mock_file.assert_called_once_with("path/to/empty_file.txt") + assert mock_namespace.content_param == "" + + +def test_file_not_found_error( + text_or_file_action_instance, mock_parser, mock_namespace +): + """ + Tests that a FileNotFoundError is propagated when the file specified by '@' does not exist. + """ + value = "@non_existent_file.txt" + option_string = "--text-or-file" + + # Patch open to raise FileNotFoundError + with patch("builtins.open", side_effect=FileNotFoundError): + with pytest.raises(FileNotFoundError): + text_or_file_action_instance( + mock_parser, mock_namespace, value, option_string + ) + + # Ensure no attribute was set on the namespace + with pytest.raises(AttributeError): + mock_namespace.content_param + + +def test_load_map_single_entry(): + """ + Test that load_map correctly parses a single username=name entry. + """ + file_content = "user1=Alice Smith\n" + # Use patch.object to mock builtins.open and simulate file reading + with patch("builtins.open", mock_open(read_data=file_content)) as mock_file: + result = load_map("dummy_map.txt") + + # Assert that open was called with the correct filename and mode + mock_file.assert_called_once_with("dummy_map.txt", "r") + # Assert the mapping is correct + assert result == {"user1": "Alice Smith"} + + +def test_load_map_multiple_entries(): + """ + Test that load_map correctly parses multiple username=name entries. + """ + file_content = "user1=Alice Smith\n" "user2=Bob Johnson\n" "admin=Charlie Brown\n" + with patch("builtins.open", mock_open(read_data=file_content)) as mock_file: + result = load_map("multi_entry_map.txt") + + mock_file.assert_called_once_with("multi_entry_map.txt", "r") + assert result == { + "user1": "Alice Smith", + "user2": "Bob Johnson", + "admin": "Charlie Brown", + } + + +def test_load_map_empty_file(): + """ + Test that load_map returns an empty dictionary for an empty file. + """ + file_content = "" + with patch("builtins.open", mock_open(read_data=file_content)) as mock_file: + result = load_map("empty_map.txt") + + mock_file.assert_called_once_with("empty_map.txt", "r") + assert result == {} + + +def test_load_map_file_not_found(): + """ + Test that load_map raises FileNotFoundError when the file does not exist. + """ + # Patch open to raise FileNotFoundError + with patch("builtins.open", side_effect=FileNotFoundError): + with pytest.raises(FileNotFoundError): + load_map("non_existent_map.txt") + + +def test_load_map_line_with_multiple_equals(): + """ + Test that load_map handles lines with multiple '=' signs correctly (splits only on first). + """ + file_content = "user_extra=First Last=ExtraInfo\n" + with patch("builtins.open", mock_open(read_data=file_content)): + result = load_map("multiple_equals_map.txt") + assert result == {"user_extra": "First Last=ExtraInfo"}