From c2a37b748d7e64f82e351ee0d713f09c998c339c Mon Sep 17 00:00:00 2001 From: Steve Milner Date: Fri, 8 Aug 2025 14:28:27 -0400 Subject: [PATCH] test: Add tests for ds Assisted-by: Gemini Flash 2.5 Flash --- .gitignore | 1 + test/test_card.py | 95 ++++++++++++++++++++++ test/test_csvexp.py | 190 +++++++++++++++++++++++++++++++++++++++++++ test/test_jql.py | 191 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 test/test_card.py create mode 100644 test/test_csvexp.py create mode 100644 test/test_jql.py diff --git a/.gitignore b/.gitignore index 24a9492..6875975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.csv __pycache__ CLE_* +.coverage diff --git a/test/test_card.py b/test/test_card.py new file mode 100644 index 0000000..0809e22 --- /dev/null +++ b/test/test_card.py @@ -0,0 +1,95 @@ +# 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 + +from sbs.ds.card import Card + + +def test_card_initialization(): + """ + Test that the Card object initializes correctly with all provided attributes. + """ + card = Card( + key="PROJ-123", + assignee="John Doe", + summary="Implement login feature", + description="User should be able to log in with username and password.", + story_points=5, + ) + + assert card.key == "PROJ-123" + assert card.assignee == "John Doe" + assert card.summary == "Implement login feature" + assert ( + card.description == "User should be able to log in with username and password." + ) + assert card.story_points == 5 + + +def test_card_repr_method(): + """ + Test the __repr__ method to ensure it returns the expected string format. + """ + card = Card( + key="PROJ-456", + assignee="Jane Smith", + summary="Fix bug on dashboard", + description="Dashboard not displaying correct data.", + story_points=3, + ) + expected_repr = """Key: PROJ-456 +Assignee: Jane Smith +Story Points: 3 +Summary: Fix bug on dashboard +Description: Dashboard not displaying correct data.""" + assert repr(card) == expected_repr + + +def test_card_attributes_can_be_accessed(): + """ + Test that individual attributes of the Card object can be accessed after initialization. + """ + card = Card( + key="TASK-001", + assignee="Alice Brown", + summary="Refactor old code", + description="Clean up deprecated functions in module X.", + story_points=8, + ) + + assert card.key == "TASK-001" + assert card.assignee == "Alice Brown" + assert card.summary == "Refactor old code" + assert card.description == "Clean up deprecated functions in module X." + assert card.story_points == 8 + + +def test_card_story_points_zero(): + """ + Test initialization with zero story points. + """ + card = Card( + key="BUG-007", + assignee="Bob White", + summary="Minor text typo", + description="Fix typo on homepage footer.", + story_points=0, + ) + assert card.story_points == 0 + + +def test_card_empty_strings(): + """ + Test initialization with empty strings for key, assignee, summary, and description. + """ + card = Card(key="", assignee="", summary="", description="", story_points=1) + assert card.key == "" + assert card.assignee == "" + assert card.summary == "" + assert card.description == "" + assert card.story_points == 1 diff --git a/test/test_csvexp.py b/test/test_csvexp.py new file mode 100644 index 0000000..bbf2965 --- /dev/null +++ b/test/test_csvexp.py @@ -0,0 +1,190 @@ +# 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 csv + +from unittest.mock import patch, mock_open + +from sbs.ds.card import Card +from sbs.ds.csvexp import csvexp + + +def test_csvexp_reads_single_card(): + """ + Test that csvexp correctly reads a single row from a CSV and yields one Card object. + """ + # Simulate CSV content as a string + csv_content = ( + "Issue key,Assignee,Summary,Description,Custom field (Story Points)\n" + "PROJ-1,John Doe,Task 1,Desc 1,5\n" + ) + + # Use patch.object to mock csv.DictReader's __iter__ method + # and mock_open to simulate file reading + with patch("builtins.open", mock_open(read_data=csv_content)) as mock_file: + # Mock csv.DictReader. We'll manually specify its behavior. + # When csv.DictReader is called, it returns an iterable. + # We need to ensure that iterable provides the dictionary rows. + mock_reader_instance = iter( + [ + { + "Issue key": "PROJ-1", + "Assignee": "John Doe", + "Summary": "Task 1", + "Description": "Desc 1", + "Custom field (Story Points)": "5", + } + ] + ) + with patch( + "csv.DictReader", return_value=mock_reader_instance + ) as mock_dict_reader: + cards = list(csvexp("dummy.csv")) + + # Assertions + mock_file.assert_called_once_with( + "dummy.csv", "r" + ) # Check if open was called + mock_dict_reader.assert_called_once_with( + mock_file() + ) # Check if DictReader was called with the file handle + + assert len(cards) == 1 + card = cards[0] + assert isinstance(card, Card) + assert card.key == "PROJ-1" + assert card.assignee == "John Doe" + assert card.summary == "Task 1" + assert card.description == "Desc 1" + assert card.story_points == "5" # Story points are read as string from CSV + + +def test_csvexp_reads_multiple_cards(): + """ + Test that csvexp correctly reads multiple rows from a CSV and yields multiple Card objects. + """ + csv_content = ( + "Issue key,Assignee,Summary,Description,Custom field (Story Points)\n" + "PROJ-1,John Doe,Task 1,Desc 1,5\n" + "PROJ-2,Jane Smith,Task 2,Desc 2,3\n" + "PROJ-3,Peter Jones,Task 3,Desc 3,8\n" + ) + + with patch("builtins.open", mock_open(read_data=csv_content)) as mock_file: + mock_reader_instance = iter( + [ + { + "Issue key": "PROJ-1", + "Assignee": "John Doe", + "Summary": "Task 1", + "Description": "Desc 1", + "Custom field (Story Points)": "5", + }, + { + "Issue key": "PROJ-2", + "Assignee": "Jane Smith", + "Summary": "Task 2", + "Description": "Desc 2", + "Custom field (Story Points)": "3", + }, + { + "Issue key": "PROJ-3", + "Assignee": "Peter Jones", + "Summary": "Task 3", + "Description": "Desc 3", + "Custom field (Story Points)": "8", + }, + ] + ) + with patch("csv.DictReader", return_value=mock_reader_instance): + cards = list(csvexp("multiple_cards.csv")) + + assert len(cards) == 3 + + assert cards[0].key == "PROJ-1" + assert cards[1].assignee == "Jane Smith" + assert cards[2].story_points == "8" + + +def test_csvexp_handles_empty_csv(): + """ + Test that csvexp returns an empty list when the CSV file is empty (only header or no data). + """ + csv_content = "Issue key,Assignee,Summary,Description,Custom field (Story Points)\n" + + with patch("builtins.open", mock_open(read_data=csv_content)) as mock_file: + # csv.DictReader on an empty file (after header) will yield nothing + mock_reader_instance = iter([]) + with patch("csv.DictReader", return_value=mock_reader_instance): + cards = list(csvexp("empty.csv")) + assert len(cards) == 0 + + +def test_csvexp_handles_missing_file(): + """ + Test that csvexp raises a FileNotFoundError if the specified CSV file does not exist. + """ + with patch("builtins.open", side_effect=FileNotFoundError): + with pytest.raises(FileNotFoundError): + list(csvexp("non_existent.csv")) + + +def test_csvexp_handles_missing_column(): + """ + Test that csvexp raises a KeyError if a required column is missing from the CSV. + """ + csv_content = ( + "Issue key,Assignee,Summary,Description,Missing Column\n" + "PROJ-1,John Doe,Task 1,Desc 1,5\n" + ) + + with patch("builtins.open", mock_open(read_data=csv_content)) as mock_file: + # Simulate DictReader providing a row missing a key + mock_reader_instance = iter( + [ + { + "Issue key": "PROJ-1", + "Assignee": "John Doe", + "Summary": "Task 1", + "Description": "Desc 1", + "Missing Column": "5", + } # Missing "Custom field (Story Points)" + ] + ) + with patch("csv.DictReader", return_value=mock_reader_instance): + with pytest.raises(KeyError): + list(csvexp("missing_column.csv")) + + +def test_csvexp_ignore_parameter(): + """ + Test that the `ignore` parameter is correctly accepted and does not interfere. + """ + csv_content = ( + "Issue key,Assignee,Summary,Description,Custom field (Story Points)\n" + "PROJ-1,John Doe,Task 1,Desc 1,5\n" + ) + + with patch("builtins.open", mock_open(read_data=csv_content)) as mock_file: + mock_reader_instance = iter( + [ + { + "Issue key": "PROJ-1", + "Assignee": "John Doe", + "Summary": "Task 1", + "Description": "Desc 1", + "Custom field (Story Points)": "5", + } + ] + ) + with patch("csv.DictReader", return_value=mock_reader_instance): + # Pass an extra keyword argument that should be ignored + cards = list(csvexp("dummy.csv", extra_param="value")) + + assert len(cards) == 1 + assert cards[0].key == "PROJ-1" diff --git a/test/test_jql.py b/test/test_jql.py new file mode 100644 index 0000000..143922f --- /dev/null +++ b/test/test_jql.py @@ -0,0 +1,191 @@ +# 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 + +from unittest.mock import patch, MagicMock +from urllib import parse + +from sbs.ds.card import Card +from sbs.ds.jql import jira + + +@pytest.fixture +def mock_response(): + """ + A pytest fixture to create a mock requests.Response object. + """ + mock_resp = MagicMock() + mock_resp.status_code = 200 + return mock_resp + + +def test_jira_fetches_and_parses_single_issue(mock_response): + """ + Test that the jira function correctly fetches and parses a single Jira issue. + """ + # Define the mock JSON response from Jira API + mock_jira_json = { + "issues": [ + { + "key": "PROJ-123", + "fields": { + "assignee": {"name": "John Doe"}, + "summary": "Implement feature A", + "description": "Details for feature A implementation.", + "customfield_12310243": 5, + }, + } + ] + } + mock_response.json.return_value = mock_jira_json + + # Patch requests.get to return our mock_response + with patch("requests.get", return_value=mock_response) as mock_get: + endpoint = "https://jira.example.com/rest/api/2" + token = "test_token" + query = "project = PROJ AND status = Open" + + cards = list(jira(endpoint, token, query)) + + # Assertions + expected_fjql = parse.quote_plus(query) + expected_fields = parse.quote_plus( + "assignee,summary,description,customfield_12310243" + ) + expected_url = f"{endpoint}/search/?fields={expected_fields}&maxResults=100&jql={expected_fjql}" + + mock_get.assert_called_once_with( + expected_url, headers={"Authorization": f"Bearer {token}"} + ) + + assert len(cards) == 1 + card = cards[0] + assert isinstance(card, Card) + assert card.key == "PROJ-123" + assert card.assignee == "John Doe" + assert card.summary == "Implement feature A" + assert card.description == "Details for feature A implementation." + assert card.story_points == 5 + + +def test_jira_fetches_and_parses_multiple_issues(mock_response): + """ + Test that the jira function correctly fetches and parses multiple Jira issues. + """ + mock_jira_json = { + "issues": [ + { + "key": "PROJ-1", + "fields": { + "assignee": {"name": "User A"}, + "summary": "Task 1", + "description": "Description 1", + "customfield_12310243": 3, + }, + }, + { + "key": "PROJ-2", + "fields": { + "assignee": {"name": "User B"}, + "summary": "Task 2", + "description": "Description 2", + "customfield_12310243": 8, + }, + }, + ] + } + mock_response.json.return_value = mock_jira_json + + with patch("requests.get", return_value=mock_response): + cards = list(jira("dummy_endpoint", "dummy_token", "dummy_query")) + + assert len(cards) == 2 + assert cards[0].key == "PROJ-1" + assert cards[1].assignee == "User B" + assert cards[1].story_points == 8 + + +def test_jira_handles_issue_with_no_assignee(mock_response): + """ + Test that the jira function correctly handles issues where the assignee field is missing. + It should default the assignee name to an empty string. + """ + mock_jira_json = { + "issues": [ + { + "key": "PROJ-BUG", + "fields": { + "summary": "Untriaged Bug", + "description": "This bug has no assignee.", + "customfield_12310243": 1, + }, + } + ] + } + mock_response.json.return_value = mock_jira_json + + with patch("requests.get", return_value=mock_response): + cards = list(jira("dummy_endpoint", "dummy_token", "dummy_query")) + + assert len(cards) == 1 + card = cards[0] + assert card.key == "PROJ-BUG" + assert card.assignee == "" # Should be an empty string + assert card.summary == "Untriaged Bug" + assert card.story_points == 1 + + +def test_jira_handles_empty_issues_list(mock_response): + """ + Test that the jira function returns an empty list if the Jira API returns no issues. + """ + mock_jira_json = {"issues": []} + mock_response.json.return_value = mock_jira_json + + with patch("requests.get", return_value=mock_response): + cards = list(jira("dummy_endpoint", "dummy_token", "dummy_query")) + assert len(cards) == 0 + + +def test_jira_handles_api_error_response(mock_response): + """ + Test that the jira function gracefully handles a non-200 HTTP status code + by letting the requests library's behavior (e.g., raise_for_status) propagate + or by handling potential JSON decoding errors. + """ + mock_response.status_code = 404 + # Simulate a JSON structure even for an error, as .json() is always called + mock_response.json.return_value = {"errorMessages": ["Issue Does Not Exist"]} + + # Patch requests.get to return our mock_response + with patch("requests.get", return_value=mock_response) as mock_get: + endpoint = "https://jira.example.com/rest/api/2" + token = "test_token" + query = "project = NONEXISTENT" + + # The original code calls .json() directly, so it expects JSON. + # If the API returns a non-200 status and valid JSON, the code + # will proceed. If it returns non-JSON, .json() would raise an error. + # For this test, we assume .json() works, and we are testing + # how the internal logic handles the 'issues' key not being present + # or the structure being different. + # In this specific case, the KeyError will be raised because 'issues' + # is not in the mock_jira_json for an error scenario. list() is used to + # call the underlying __next__(). + with pytest.raises(KeyError): + list(jira(endpoint, token, query)) + + expected_fjql = parse.quote_plus(query) + expected_fields = parse.quote_plus( + "assignee,summary,description,customfield_12310243" + ) + expected_url = f"{endpoint}/search/?fields={expected_fields}&maxResults=100&jql={expected_fjql}" + + mock_get.assert_called_once_with( + expected_url, headers={"Authorization": f"Bearer {token}"} + )