import unittest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime
from preloop.sync.trackers.gitlab import GitLabTracker
# Assuming logger is available or mockable; for now, we proceed
# from preloop.sync.config import logger # If needed for more complex logger mocking
class TestGitLabTrackerComments(unittest.IsolatedAsyncioTestCase):
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab") # Patch where it's looked up
async def test_get_issues_fetches_and_transforms_comments(
self, mock_gitlab_constructor
):
# 1. Setup Mocks for GitLab API interaction
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None # Mock auth call during init
# Mock project object
mock_project = MagicMock()
mock_project.path_with_namespace = "testgroup/testproject"
# Attributes that might be accessed by the tracker
mock_project.attributes = {
"id": "proj-123",
"name": "Test Project",
"web_url": "http://gitlab.com/testgroup/testproject",
"path_with_namespace": "testgroup/testproject", # Ensure this is available
}
mock_gl_instance.projects.get.return_value = mock_project
# Mock issue object
mock_issue1 = MagicMock()
mock_issue1.iid = 1 # Internal ID for GitLab issues
mock_issue1.title = "Test Issue 1"
mock_issue1.description = "Description for issue 1"
mock_issue1.state = "opened"
mock_issue1.created_at = "2023-01-01T10:00:00.000Z"
mock_issue1.updated_at = "2023-01-02T11:00:00.000Z"
mock_issue1.web_url = "http://gitlab.com/testgroup/testproject/issues/1"
mock_issue1.labels = ["bug", "critical"]
mock_issue1.assignees = [
{
"id": "user-id-1",
"username": "user1",
"avatar_url": "http://example.com/avatar.png",
}
]
mock_issue1.attributes = {
"iid": 1,
"title": "Test Issue 1",
"description": "Description for issue 1",
"state": "opened",
"created_at": "2023-01-01T10:00:00.000Z",
"updated_at": "2023-01-02T11:00:00.000Z",
"web_url": "http://gitlab.com/testgroup/testproject/issues/1",
"labels": ["bug", "critical"],
"assignees": [
{
"id": "user-id-1",
"username": "user1",
"avatar_url": "http://example.com/avatar.png",
}
],
}
# Mock user comment (note)
mock_note_user = MagicMock()
mock_note_user.id = 101
mock_note_user.body = "This is a user comment."
mock_note_user.system = False
mock_note_user.author = {
"id": "user-id-1",
"username": "commenter1",
"name": "Commenter One",
"avatar_url": "http://example.com/avatar.png",
}
mock_note_user.created_at = "2023-01-01T12:00:00.000Z"
mock_note_user.updated_at = "2023-01-01T12:05:00.000Z"
for key, value in {
"id": 101,
"body": "This is a user comment.",
"system": False,
"author": {
"id": "user-id-1",
"username": "commenter1",
"name": "Commenter One",
"avatar_url": "http://example.com/avatar.png",
},
"created_at": "2023-01-01T12:00:00.000Z",
"updated_at": "2023-01-01T12:05:00.000Z",
}.items():
setattr(mock_note_user, key, value)
# Mock system note
mock_note_system = MagicMock()
mock_note_system.id = 102
mock_note_system.body = "User added label ~bug"
mock_note_system.system = True
mock_note_system.author = {
"id": "user-id-2",
"username": "gitlab-bot",
"name": "GitLab Bot",
}
mock_note_system.created_at = "2023-01-01T13:00:00.000Z"
mock_note_system.updated_at = "2023-01-01T13:00:00.000Z"
for key, value in {
"id": 102,
"body": "User added label ~bug",
"system": True,
"author": {
"id": "user-id-2",
"username": "gitlab-bot",
"name": "GitLab Bot",
},
"created_at": "2023-01-01T13:00:00.000Z",
"updated_at": "2023-01-01T13:00:00.000Z",
}.items():
setattr(mock_note_system, key, value)
mock_issue1.notes.list.return_value = [mock_note_user, mock_note_system]
mock_project.issues.list.return_value = [mock_issue1]
# 2. Initialize Tracker
# Provide minimal connection_details as expected by the constructor
tracker = GitLabTracker(
tracker_id="test-gitlab-tracker",
api_key="fake_token",
connection_details={"url": "http://gitlab.com"},
)
# 3. Call get_issues
issues_with_comments = await tracker.get_issues(
organization_id="org-1", project_id="proj-123"
)
# 4. Assertions
self.assertEqual(len(issues_with_comments), 1, "Should return one issue")
issue_data = issues_with_comments[0]
self.assertIn("comments", issue_data)
self.assertEqual(
len(issue_data["comments"]),
1,
"Should only include user comments, system notes filtered out",
)
comment_data = issue_data["comments"][0]
self.assertEqual(comment_data["id"], str(mock_note_user.id))
self.assertEqual(comment_data["body"], mock_note_user.body)
self.assertEqual(
comment_data["author"]["name"], mock_note_user.author["username"]
)
self.assertEqual(
comment_data["created_at"],
datetime.strptime(mock_note_user.created_at, "%Y-%m-%dT%H:%M:%S.%fZ"),
)
self.assertEqual(
comment_data["updated_at"],
datetime.strptime(mock_note_user.updated_at, "%Y-%m-%dT%H:%M:%S.%fZ"),
)
# Verify API calls
mock_gitlab_constructor.assert_called_once_with(
"http://gitlab.com", private_token="fake_token"
)
mock_gl_instance.projects.get.assert_called_once_with("proj-123")
mock_project.issues.list.assert_called_once_with(
all=True, include_metadata=True
)
mock_issue1.notes.list.assert_called_once_with(
all=True, sort="asc", order_by="created_at"
)
if __name__ == "__main__":
unittest.main()
class TestGitLabTracker(unittest.IsolatedAsyncioTestCase):
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_get_organizations(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_group = MagicMock()
mock_group.id = 1
mock_group.name = "Test Group"
mock_group.web_url = "http://gitlab.com/groups/test-group"
mock_gl_instance.groups.list.return_value = [mock_group]
tracker = GitLabTracker("tracker-1", "api-key", {})
orgs = await tracker.get_organizations()
self.assertEqual(len(orgs), 1)
self.assertEqual(orgs[0]["name"], "Test Group")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_get_projects(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
mock_project = MagicMock()
mock_project.attributes = {
"id": 1,
"name": "Test Project",
"description": "A test project",
"web_url": "http://gitlab.com/test-group/test-project",
"path_with_namespace": "test-group/test-project",
}
mock_group.projects.list.return_value = [mock_project]
tracker = GitLabTracker("tracker-1", "api-key", {})
projects = await tracker.get_projects("group-1")
self.assertEqual(len(projects), 1)
self.assertEqual(projects[0]["name"], "Test Project")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_get_issue(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_project = MagicMock()
mock_project.path_with_namespace = "testgroup/testproject"
mock_gl_instance.projects.get.return_value = mock_project
mock_issue = MagicMock()
mock_issue.id = 12345
mock_issue.iid = 1
mock_issue.title = "Test Issue"
mock_issue.description = "Description for issue"
mock_issue.state = "opened"
mock_issue.created_at = "2023-01-01T10:00:00.000Z"
mock_issue.updated_at = "2023-01-02T11:00:00.000Z"
mock_issue.web_url = "http://gitlab.com/testgroup/testproject/issues/1"
mock_issue.labels = ["bug", "critical"]
mock_issue.assignees = [{"username": "user1"}]
# Add attributes dict for the parser
mock_issue.attributes = {
"id": 12345,
"iid": 1,
"title": "Test Issue",
"description": "Description for issue",
"state": "opened",
"created_at": "2023-01-01T10:00:00.000Z",
"updated_at": "2023-01-02T11:00:00.000Z",
"web_url": "http://gitlab.com/testgroup/testproject/issues/1",
"labels": ["bug", "critical"],
"assignee": None,
"assignees": [],
"author": {
"id": 123,
"name": "Author",
"avatar_url": "https://example.com/avatar.png",
},
"_links": {"self": "http://gitlab.com/api/v4/projects/proj-1/issues/1"},
}
mock_project.issues.get.return_value = mock_issue
tracker = GitLabTracker("tracker-1", "api-key", {"project_id": "proj-1"})
issue = await tracker.get_issue("1")
# Verify API calls
mock_gl_instance.projects.get.assert_called_once_with("proj-1")
mock_project.issues.get.assert_called_once_with("1")
# Verify issue data transformation - Now result is an Issue object
self.assertEqual(issue.title, "Test Issue")
self.assertEqual(issue.id, "12345")
self.assertEqual(issue.key, "testgroup/testproject#1")
self.assertEqual(issue.status.id, "opened")
self.assertEqual(issue.labels, ["bug", "critical"])
self.assertEqual(issue.description, "Description for issue")
self.assertEqual(issue.url, "http://gitlab.com/testgroup/testproject/issues/1")
class TestGitLabTrackerWebhooks(unittest.IsolatedAsyncioTestCase):
@patch("preloop.sync.trackers.gitlab.crud_webhook")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_group_webhook_success(
self, mock_gitlab_constructor, mock_crud_webhook
):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
mock_group.hooks.list.return_value = [] # No existing hooks
mock_hook = MagicMock()
mock_hook.id = "hook-123"
mock_group.hooks.create.return_value = mock_hook
tracker = GitLabTracker("tracker-1", "api-key", {})
tracker.delete_webhook = AsyncMock()
tracker.get_webhooks = AsyncMock()
tracker.is_webhook_registered = AsyncMock()
tracker.unregister_all_webhooks = AsyncMock()
tracker.unregister_webhook = AsyncMock()
mock_db_session = MagicMock()
mock_organization = MagicMock()
mock_organization.identifier = "group-1"
mock_organization.id = "org-db-id-1"
result = await tracker.register_group_webhook(
mock_db_session,
mock_organization,
"http://test.com/webhook",
"secret-token",
)
self.assertTrue(result)
mock_group.hooks.create.assert_called_once()
mock_crud_webhook.create.assert_called_once()
@patch("preloop.sync.trackers.gitlab.crud_webhook")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_group_webhook_already_exists(
self, mock_gitlab_constructor, mock_crud_webhook
):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
mock_existing_hook = MagicMock()
mock_existing_hook.url = "http://test.com/webhook"
mock_group.hooks.list.return_value = [mock_existing_hook]
tracker = GitLabTracker("tracker-1", "api-key", {})
mock_db_session = MagicMock()
mock_organization = MagicMock()
mock_organization.identifier = "group-1"
result = await tracker.register_group_webhook(
mock_db_session,
mock_organization,
"http://test.com/webhook",
"secret-token",
)
self.assertTrue(result)
mock_group.hooks.create.assert_not_called()
mock_crud_webhook.create.assert_not_called()
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_group_webhook_not_supported(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
# Simulate 404 error when listing hooks
from gitlab.exceptions import GitlabListError
mock_response = MagicMock()
mock_response.status_code = 404
mock_group.hooks.list.side_effect = GitlabListError(
response_code=404, response_body="404 Group Hooks Not Found"
)
tracker = GitLabTracker("tracker-1", "api-key", {})
tracker.delete_webhook = AsyncMock()
tracker.get_webhooks = AsyncMock()
tracker.is_webhook_registered = AsyncMock()
tracker.unregister_all_webhooks = AsyncMock()
tracker.unregister_webhook = AsyncMock()
mock_db_session = MagicMock()
mock_organization = MagicMock()
mock_organization.identifier = "group-1"
result = await tracker.register_group_webhook(
mock_db_session,
mock_organization,
"http://test.com/webhook",
"secret-token",
)
self.assertEqual(result, "group_hooks_not_supported")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_group_webhook_api_error(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
mock_group.hooks.list.return_value = [] # No existing hooks
# Simulate 500 error when creating hook
from gitlab.exceptions import GitlabCreateError
mock_group.hooks.create.side_effect = GitlabCreateError(
response_code=500, response_body="Internal Server Error"
)
tracker = GitLabTracker("tracker-1", "api-key", {})
mock_db_session = MagicMock()
mock_organization = MagicMock()
mock_organization.identifier = "group-1"
result = await tracker.register_group_webhook(
mock_db_session,
mock_organization,
"http://test.com/webhook",
"secret-token",
)
self.assertFalse(result)
@patch("preloop.sync.trackers.gitlab.crud_webhook")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_project_webhook_success(
self, mock_gitlab_constructor, mock_crud_webhook
):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_project = MagicMock()
mock_gl_instance.projects.get.return_value = mock_project
mock_project.hooks.list.return_value = [] # No existing hooks
mock_hook = MagicMock()
mock_hook.id = "hook-456"
mock_project.hooks.create.return_value = mock_hook
tracker = GitLabTracker("tracker-1", "api-key", {})
tracker.delete_webhook = AsyncMock()
tracker.get_webhooks = AsyncMock()
tracker.is_webhook_registered = AsyncMock()
tracker.unregister_all_webhooks = AsyncMock()
tracker.unregister_webhook = AsyncMock()
mock_db_session = MagicMock()
mock_db_project = MagicMock()
mock_db_project.identifier = "proj-1"
mock_db_project.id = "proj-db-id-1"
result = await tracker.register_project_webhook(
mock_db_session,
mock_db_project,
"http://test.com/webhook",
"secret-token",
)
self.assertTrue(result)
mock_project.hooks.create.assert_called_once()
mock_crud_webhook.create.assert_called_once()
@patch("preloop.sync.trackers.gitlab.crud_webhook")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_project_webhook_already_exists(
self, mock_gitlab_constructor, mock_crud_webhook
):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_project = MagicMock()
mock_gl_instance.projects.get.return_value = mock_project
mock_existing_hook = MagicMock()
mock_existing_hook.url = "http://test.com/webhook"
mock_project.hooks.list.return_value = [mock_existing_hook]
tracker = GitLabTracker("tracker-1", "api-key", {})
tracker.delete_webhook = AsyncMock()
tracker.get_webhooks = AsyncMock()
tracker.is_webhook_registered = AsyncMock()
tracker.unregister_all_webhooks = AsyncMock()
tracker.unregister_webhook = AsyncMock()
mock_db_session = MagicMock()
mock_db_project = MagicMock()
mock_db_project.identifier = "proj-1"
result = await tracker.register_project_webhook(
mock_db_session,
mock_db_project,
"http://test.com/webhook",
"secret-token",
)
self.assertTrue(result)
mock_project.hooks.create.assert_not_called()
mock_crud_webhook.create.assert_not_called()
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_register_project_webhook_api_error(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_project = MagicMock()
mock_gl_instance.projects.get.return_value = mock_project
mock_project.hooks.list.return_value = [] # No existing hooks
# Simulate 500 error when creating hook
from gitlab.exceptions import GitlabCreateError
mock_project.hooks.create.side_effect = GitlabCreateError(
response_code=500, response_body="Internal Server Error"
)
tracker = GitLabTracker("tracker-1", "api-key", {})
tracker.delete_webhook = AsyncMock()
tracker.get_webhooks = AsyncMock()
tracker.is_webhook_registered = AsyncMock()
tracker.unregister_all_webhooks = AsyncMock()
tracker.unregister_webhook = AsyncMock()
mock_db_session = MagicMock()
mock_db_project = MagicMock()
mock_db_project.identifier = "proj-1"
result = await tracker.register_project_webhook(
mock_db_session,
mock_db_project,
"http://test.com/webhook",
"secret-token",
)
self.assertFalse(result)
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_unregister_project_webhook_success(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_project = MagicMock()
mock_gl_instance.projects.get.return_value = mock_project
tracker = GitLabTracker("tracker-1", "api-key", {})
mock_db_session = MagicMock()
mock_webhook = MagicMock()
mock_webhook.project.identifier = "proj-1"
mock_webhook.external_id = "hook-456"
mock_webhook.organization = None
result = await tracker.unregister_webhook(mock_db_session, mock_webhook)
self.assertTrue(result)
mock_project.hooks.delete.assert_called_once_with("hook-456")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_unregister_group_webhook_success(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_group = MagicMock()
mock_gl_instance.groups.get.return_value = mock_group
tracker = GitLabTracker("tracker-1", "api-key", {})
mock_db_session = MagicMock()
mock_webhook = MagicMock()
mock_webhook.project = None
mock_webhook.organization.identifier = "group-1"
mock_webhook.external_id = "hook-123"
result = await tracker.unregister_webhook(mock_db_session, mock_webhook)
self.assertTrue(result)
mock_group.hooks.delete.assert_called_once_with("hook-123")
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_unregister_webhook_api_error(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_gl_instance.auth.return_value = None
mock_project = MagicMock()
mock_gl_instance.projects.get.return_value = mock_project
from gitlab.exceptions import GitlabDeleteError
mock_project.hooks.delete.side_effect = GitlabDeleteError(
response_code=500, response_body="Internal Server Error"
)
tracker = GitLabTracker("tracker-1", "api-key", {})
mock_db_session = MagicMock()
mock_webhook = MagicMock()
mock_webhook.project.identifier = "proj-1"
mock_webhook.external_id = "hook-456"
mock_webhook.organization = None
result = await tracker.unregister_webhook(mock_db_session, mock_webhook)
self.assertFalse(result)
@patch("preloop.sync.trackers.gitlab.gitlab.Gitlab")
async def test_get_issue(self, mock_gitlab_constructor):
mock_gl_instance = MagicMock()
mock_gitlab_constructor.return_value = mock_gl_instance
mock_project = MagicMock()
mock_project.path_with_namespace = "testgroup/testproject"
mock_gl_instance.projects.get.return_value = mock_project
mock_issue = MagicMock()
mock_issue.id = 12345
mock_issue.iid = 1
mock_issue.title = "Test Issue"
mock_issue.description = "Description for issue"
mock_issue.state = "opened"
mock_issue.created_at = "2023-01-01T10:00:00.000Z"
mock_issue.updated_at = "2023-01-02T11:00:00.000Z"
mock_issue.web_url = "http://gitlab.com/testgroup/testproject/issues/1"
mock_issue.labels = ["bug", "critical"]
mock_issue.assignees = [{"username": "user1"}]
# Add attributes dict for the parser
mock_issue.attributes = {
"id": 12345,
"iid": 1,
"title": "Test Issue",
"description": "Description for issue",
"state": "opened",
"created_at": "2023-01-01T10:00:00.000Z",
"updated_at": "2023-01-02T11:00:00.000Z",
"web_url": "http://gitlab.com/testgroup/testproject/issues/1",
"labels": ["bug", "critical"],
"assignee": None,
"assignees": [],
"author": {
"id": 123,
"name": "Author",
"avatar_url": "https://example.com/avatar.png",
},
"_links": {"self": "http://gitlab.com/api/v4/projects/proj-1/issues/1"},
}
mock_project.issues.get.return_value = mock_issue
tracker = GitLabTracker("tracker-1", "api-key", {"project_id": "proj-1"})
issue = await tracker.get_issue("1")
# Verify API calls
mock_gl_instance.projects.get.assert_called_once_with("proj-1")
mock_project.issues.get.assert_called_once_with("1")
# Verify issue data transformation - Now result is an Issue object
self.assertEqual(issue.title, "Test Issue")
self.assertEqual(issue.id, "12345")
self.assertEqual(issue.key, "testgroup/testproject#1")
self.assertEqual(issue.status.id, "opened")
self.assertEqual(issue.labels, ["bug", "critical"])
self.assertEqual(issue.description, "Description for issue")
self.assertEqual(issue.url, "http://gitlab.com/testgroup/testproject/issues/1")