This commit is contained in:
2026-07-22 21:41:15 +02:00
parent 601ab6838f
commit 3c975a248e
6 changed files with 1449 additions and 0 deletions
View File
+797
View File
@@ -0,0 +1,797 @@
"""
Tests for Jira ↔ Jotty Bidirectional Sync Script
Run with: python -m pytest tests/ -v
Or: pip install responses pytest && python -m pytest tests/ -v
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Ensure the parent directory is on sys.path so we can import jira_jotty_sync
# ---------------------------------------------------------------------------
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import responses # type: ignore[import-untyped]
import jira_jotty_sync as sync_module
# ===================================================================
# Fixtures / Helpers
# ===================================================================
SAMPLE_JIRA_ISSUE = {
"key": "PROJ-42",
"fields": {
"summary": "Implement user authentication",
"status": {"name": "IN PROGRESS"},
"description": "Add login and token refresh",
},
}
SAMPLE_JOTTY_TASK = {
"id": "task-uuid-001",
"title": "Jira Stories",
"category": "Work",
"type": "task",
"statuses": [
{"id": "todo", "label": "To Do", "order": 0},
{"id": "in_progress", "label": "In Progress", "order": 1},
{"id": "completed", "label": "Completed", "order": 2},
],
"items": [
{
"index": 0,
"text": "[PROJ-42] Implement user authentication",
"status": "in_progress",
"completed": False,
},
{
"index": 1,
"text": "[PROJ-99] Write API documentation",
"status": "todo",
"completed": False,
},
],
}
SAMPLE_JOTTY_CHECKLIST = {
"id": "task-uuid-001",
"title": "Jira Stories",
"category": "Work",
"type": "task",
"items": [],
}
def _make_config(**overrides):
"""Build a SyncConfig with sensible defaults, overridden by kwargs."""
cfg = sync_module.SyncConfig(
jira_base_url="https://myorg.atlassian.net",
jira_username="you@company.com",
jira_api_token="ATATT3xFfGFtoken123",
project_key="PROJ",
jql_query='project = "${PROJECT_KEY}" AND issuetype = Story',
jotty_base_url="https://jotty.example.com",
jotty_api_key="ck_testkey_abc",
checklist_title="Jira Stories",
checklist_category="Work",
mapping_file="/tmp/test_sync_mapping.json",
)
for k, v in overrides.items():
setattr(cfg, k, v)
return cfg
# ===================================================================
# 1. Jira Connection & Extraction Tests
# ===================================================================
class TestJiraConnectionAndExtraction(unittest.TestCase):
"""Test that the JiraClient can authenticate and extract stories."""
def _setup_mock(self):
self.client = sync_module.JiraClient(
"https://myorg.atlassian.net",
"you@company.com",
"ATATT3xFfGFtoken123",
)
@responses.activate
def test_jira_auth_headers(self):
"""Verify that JiraClient sends Basic auth with username:token."""
self._setup_mock()
# We'll intercept the request and check headers
captured = {}
def capture_request(req):
captured["auth"] = req.headers.get("Authorization", "")
return (200, [], json.dumps({"total": 0, "maxResults": 100, "issues": []}))
responses.add_callback(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
callback=capture_request,
)
self.client.search_stories('project = "PROJ" AND issuetype = Story')
self.assertTrue(captured["auth"].startswith("Basic "), "Auth header should be Basic")
@responses.activate
def test_search_stories_returns_issues(self):
"""search_stories should return a list of issue dicts."""
self._setup_mock()
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={
"total": 2,
"maxResults": 100,
"issues": [SAMPLE_JIRA_ISSUE],
},
status=200,
)
issues = self.client.search_stories('project = "PROJ" AND issuetype = Story')
self.assertIsInstance(issues, list)
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["key"], "PROJ-42")
@responses.activate
def test_search_stories_pagination(self):
"""search_stories should paginate through all results."""
self._setup_mock()
# First page returns partial results
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={
"total": 200,
"maxResults": 100,
"issues": [{"key": f"PROJ-{i}"} for i in range(100)],
},
status=200,
)
issues = self.client.search_stories('project = "PROJ" AND issuetype = Story')
# Should have fetched 100 from first page + 100 from second = 200
self.assertEqual(len(issues), 200)
@responses.activate
def test_search_stories_empty(self):
"""search_stories should return empty list when no issues match."""
self._setup_mock()
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={"total": 0, "maxResults": 100, "issues": []},
status=200,
)
issues = self.client.search_stories('project = "PROJ" AND issuetype = Story')
self.assertEqual(len(issues), 0)
@responses.activate
def test_get_issue(self):
"""get_issue should return a single issue dict."""
self._setup_mock()
responses.add(
responses.GET,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-42",
json=SAMPLE_JIRA_ISSUE,
status=200,
)
issue = self.client.get_issue("PROJ-42")
self.assertEqual(issue["key"], "PROJ-42")
self.assertEqual(issue["fields"]["summary"], "Implement user authentication")
@responses.activate
def test_update_status_via_transition(self):
"""update_status should use transitions API when direct PUT fails."""
self._setup_mock()
# Direct PUT succeeds (200) — no fallback needed
responses.add(
responses.PUT,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-42",
json={},
status=200,
)
self.client.update_status("PROJ-42", "IN PROGRESS")
@responses.activate
def test_update_status_fallback_to_transition(self):
"""update_status should fall back to transitions API on 403."""
self._setup_mock()
# Direct PUT fails with 403, triggering fallback to transitions
responses.add(
responses.PUT,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-42",
json={"errorMessages": ["Forbidden"], "status": 403},
status=403,
)
# Note: transitions endpoint uses 'issue' (singular), not 'issues'
responses.add(
responses.GET,
"https://myorg.atlassian.net/rest/api/3/issue/PROJ-42/transitions",
json={
"transitions": [
{
"id": "11",
"to": {"name": "IN PROGRESS"},
"fields": {},
},
],
},
status=200,
)
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/issue/PROJ-42/transitions",
json={},
status=200,
)
self.client.update_status("PROJ-42", "IN PROGRESS")
# ===================================================================
# 2. Jotty Connection & Extraction Tests
# ===================================================================
class TestJottyConnectionAndExtraction(unittest.TestCase):
"""Test that the JottyClient can authenticate and extract checklists."""
def _setup_mock(self):
self.client = sync_module.JottyClient(
"https://jotty.example.com",
"ck_testkey_abc",
)
@responses.activate
def test_jotty_api_key_header(self):
"""Verify that JottyClient sends the correct x-api-key header."""
self._setup_mock()
captured = {}
def capture_request(req):
captured["api_key"] = req.headers.get("x-api-key", "")
return (200, [], json.dumps({"checklists": []}))
responses.add_callback(
responses.GET,
"https://jotty.example.com/api/checklists",
callback=capture_request,
)
self.client.get_checklists()
self.assertEqual(captured["api_key"], "ck_testkey_abc")
@responses.activate
def test_get_checklists(self):
"""get_checklists should return a list of checklist dicts."""
self._setup_mock()
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": [SAMPLE_JOTTY_CHECKLIST]},
status=200,
)
checklists = self.client.get_checklists()
self.assertIsInstance(checklists, list)
self.assertEqual(len(checklists), 1)
self.assertEqual(checklists[0]["title"], "Jira Stories")
def test_find_or_create_task_list_existing(self):
"""find_or_create_task_list should find and return an existing task checklist."""
with responses.RequestsMock() as rsps:
self._setup_mock()
rsps.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": [SAMPLE_JOTTY_CHECKLIST]},
status=200,
)
result = self.client.find_or_create_task_list("Jira Stories", "Work")
self.assertEqual(result["id"], "task-uuid-001")
self.assertEqual(result["type"], "task")
@responses.activate
def test_find_or_create_task_list_creates_new(self):
"""find_or_create_task_list should create a new task checklist if none exists."""
self._setup_mock()
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": []}, # no existing lists
status=200,
)
responses.add(
responses.POST,
"https://jotty.example.com/api/tasks",
json={
"success": True,
"data": {
"id": "new-task-uuid",
"title": "Jira Stories",
"type": "task",
"items": [],
},
},
status=200,
)
result = self.client.find_or_create_task_list("Jira Stories", "Work")
self.assertEqual(result["id"], "new-task-uuid")
@responses.activate
def test_get_task(self):
"""get_task should return a task dict."""
self._setup_mock()
responses.add(
responses.GET,
"https://jotty.example.com/api/tasks/task-uuid-001",
json={"task": SAMPLE_JOTTY_TASK},
status=200,
)
task = self.client.get_task("task-uuid-001")
self.assertEqual(task["title"], "Jira Stories")
self.assertEqual(len(task["items"]), 2)
@responses.activate
def test_create_item(self):
"""create_item should create a new item and return its data."""
self._setup_mock()
responses.add(
responses.POST,
"https://jotty.example.com/api/tasks/task-uuid-001/items",
json={"success": True, "data": {"id": "item-123"}},
status=200,
)
result = self.client.create_item("task-uuid-001", "[PROJ-42] Test task", "todo")
self.assertEqual(result["id"], "item-123")
@responses.activate
def test_update_item_status(self):
"""update_item_status should PUT to the correct endpoint."""
self._setup_mock()
responses.add(
responses.PUT,
"https://jotty.example.com/api/tasks/task-uuid-001/items/0/status",
json={"success": True},
status=200,
)
self.client.update_item_status("task-uuid-001", 0, "in_progress")
@responses.activate
def test_check_item(self):
"""check_item should PUT to the check endpoint."""
self._setup_mock()
responses.add(
responses.PUT,
"https://jotty.example.com/api/tasks/task-uuid-001/items/0/check",
json={"success": True},
status=200,
)
self.client.check_item("task-uuid-001", 0)
@responses.activate
def test_uncheck_item(self):
"""uncheck_item should PUT to the uncheck endpoint."""
self._setup_mock()
responses.add(
responses.PUT,
"https://jotty.example.com/api/tasks/task-uuid-001/items/0/uncheck",
json={"success": True},
status=200,
)
self.client.uncheck_item("task-uuid-001", 0)
@responses.activate
def test_delete_item(self):
"""delete_item should DELETE the correct item."""
self._setup_mock()
responses.add(
responses.DELETE,
"https://jotty.example.com/api/tasks/task-uuid-001/items/0",
json={"success": True},
status=200,
)
self.client.delete_item("task-uuid-001", 0)
# ===================================================================
# 3. Sync Engine Tests (Jira → Jotty)
# ===================================================================
class TestSyncEngineJiraToJotty(unittest.TestCase):
"""Test the jira-to-jotty sync direction."""
@responses.activate
def test_sync_creates_mapping(self):
"""sync_jira_to_jotty should create a mapping entry for each issue."""
config = _make_config()
engine = sync_module.SyncEngine(config)
# Mock Jira search
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={
"total": 1,
"maxResults": 100,
"issues": [SAMPLE_JIRA_ISSUE],
},
status=200,
)
# Mock Jotty: find existing task list
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": [SAMPLE_JOTTY_CHECKLIST]},
status=200,
)
# Mock the task list creation response (since we're creating new items)
responses.add(
responses.POST,
"https://jotty.example.com/api/tasks/list-uuid-001/items",
json={"success": True, "data": {"id": "item-new"}},
status=200,
)
engine.sync_jira_to_jotty()
# Check that mapping was saved
self.assertTrue(os.path.exists(config.mapping_file))
with open(config.mapping_file) as f:
mapping = json.load(f)
self.assertIn("PROJ-42", mapping)
@responses.activate
def test_sync_updates_existing_item_status(self):
"""sync_jira_to_jotty should update status of existing mapped items."""
config = _make_config()
engine = sync_module.SyncEngine(config)
# Pre-populate the mapping file with an existing entry
mapping_data = {"PROJ-42": {"checklist_id": "list-uuid-001", "item_index": 0}}
Path(config.mapping_file).write_text(json.dumps(mapping_data))
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={
"total": 1,
"maxResults": 100,
"issues": [SAMPLE_JIRA_ISSUE],
},
status=200,
)
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": [SAMPLE_JOTTY_CHECKLIST]},
status=200,
)
responses.add(
responses.PUT,
"https://jotty.example.com/api/tasks/list-uuid-001/items/0/status",
json={"success": True},
status=200,
)
engine.sync_jira_to_jotty()
# ===================================================================
# 4. Sync Engine Tests (Jotty → Jira)
# ===================================================================
class TestSyncEngineJottyToJira(unittest.TestCase):
"""Test the jotty-to-jira sync direction."""
@responses.activate
def test_sync_pushes_status_changes(self):
"""sync_jotty_to_jira should push completed items to Jira as DONE."""
config = _make_config()
engine = sync_module.SyncEngine(config)
# Pre-populate mapping file
mapping_data = {
"PROJ-42": {"checklist_id": "task-uuid-001", "item_index": 0},
"PROJ-99": {"checklist_id": "task-uuid-001", "item_index": 1},
}
Path(config.mapping_file).write_text(json.dumps(mapping_data))
# Mock Jira: get issue status for PROJ-42 (needs change)
responses.add(
responses.GET,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-42",
json={
"key": "PROJ-42",
"fields": {"status": {"name": "TO DO"}},
},
status=200,
)
# Mock Jira: get issue status for PROJ-99 (no change needed)
responses.add(
responses.GET,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-99",
json={
"key": "PROJ-99",
"fields": {"status": {"name": "TO DO"}},
},
status=200,
)
# Mock Jira: direct PUT fails with 403 (triggers fallback to transitions)
responses.add(
responses.PUT,
"https://myorg.atlassian.net/rest/api/3/issues/PROJ-42",
json={"errorMessages": ["Forbidden"], "status": 403},
status=403,
)
# Mock Jira: get transitions for PROJ-42 (note: 'issue' singular)
responses.add(
responses.GET,
"https://myorg.atlassian.net/rest/api/3/issue/PROJ-42/transitions",
json={
"transitions": [
{"id": "11", "to": {"name": "IN PROGRESS"}, "fields": {}},
],
},
status=200,
)
# Mock Jira: update PROJ-42 to IN PROGRESS via transition
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/issue/PROJ-42/transitions",
json={},
status=200,
)
# Mock Jotty: get task list response with items
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"checklists": [SAMPLE_JOTTY_CHECKLIST]},
status=200,
)
responses.add(
responses.GET,
"https://jotty.example.com/api/tasks/task-uuid-001",
json={"task": SAMPLE_JOTTY_TASK},
status=200,
)
engine.sync_jotty_to_jira()
# ===================================================================
# 5. Config Loading Tests
# ===================================================================
class TestConfigLoading(unittest.TestCase):
"""Test that configuration is loaded correctly from various sources."""
def test_load_config_from_env(self):
"""load_config should read environment variables when no config file exists."""
with patch.dict(os.environ, {
"JIRA_BASE_URL": "https://env-test.atlassian.net",
"JIRA_USERNAME": "env@test.com",
"JIRA_API_TOKEN": "env_token_123",
"PROJECT_KEY": "ENV",
"JOTTY_BASE_URL": "https://jotty-env.example.com",
"JOTTY_API_KEY": "ck_env_key",
}):
args = MagicMock()
args.config = "/nonexistent/config.json"
args.jira_base = None
args.jira_user = None
args.jira_token = None
args.project = None
args.jotty_base = None
args.jotty_key = None
config = sync_module.load_config(args)
self.assertEqual(config.jira_base_url, "https://env-test.atlassian.net")
self.assertEqual(config.jira_username, "env@test.com")
self.assertEqual(config.project_key, "ENV")
self.assertEqual(config.jotty_api_key, "ck_env_key")
def test_load_config_from_json_file(self):
"""load_config should read from sync_config.json when it exists."""
config_content = {
"jira": {
"base_url": "https://json-test.atlassian.net",
"username": "json@test.com",
"api_token": "json_token_123",
"project_key": "JSON",
},
"jotty": {
"base_url": "https://jotty-json.example.com",
"api_key": "ck_json_key",
"checklist_title": "Custom Title",
"checklist_category": "Projects",
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config_content, f)
config_path = f.name
try:
args = MagicMock()
args.config = config_path
args.jira_base = None
args.jira_user = None
args.jira_token = None
args.project = None
args.jotty_base = None
args.jotty_key = None
config = sync_module.load_config(args)
self.assertEqual(config.jira_base_url, "https://json-test.atlassian.net")
self.assertEqual(config.checklist_title, "Custom Title")
finally:
os.unlink(config_path)
def test_cli_overrides_env(self):
"""CLI arguments should override environment variables."""
with patch.dict(os.environ, {
"JIRA_BASE_URL": "https://env.atlassian.net",
"PROJECT_KEY": "ENV",
}):
args = MagicMock()
args.config = "/nonexistent/config.json"
args.jira_base = "https://cli.atlassian.net" # CLI override
args.jira_user = None
args.jira_token = None
args.project = "CLI" # CLI override
args.jotty_base = None
args.jotty_key = None
config = sync_module.load_config(args)
self.assertEqual(config.jira_base_url, "https://cli.atlassian.net")
self.assertEqual(config.project_key, "CLI")
# ===================================================================
# 6. Mapping Persistence Tests
# ===================================================================
class TestMappingPersistence(unittest.TestCase):
"""Test that SyncMapping can save and load correctly."""
def test_save_and_load_mapping(self):
"""SyncMapping should persist mappings to disk and reload them."""
mapping = sync_module.SyncMapping()
mapping.mappings["PROJ-1"] = {"checklist_id": "cl-001", "item_index": 0}
mapping.mappings["PROJ-2"] = {"checklist_id": "cl-001", "item_index": 1}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_path = f.name
try:
mapping.save(temp_path)
loaded = sync_module.SyncMapping.load(temp_path)
self.assertIn("PROJ-1", loaded.mappings)
self.assertIn("PROJ-2", loaded.mappings)
self.assertEqual(loaded.mappings["PROJ-1"]["item_index"], 0)
finally:
os.unlink(temp_path)
def test_load_nonexistent_mapping(self):
"""SyncMapping.load should return empty mapping for non-existent file."""
loaded = sync_module.SyncMapping.load("/nonexistent/path.json")
self.assertEqual(loaded.mappings, {})
# ===================================================================
# 7. Status Mapping Tests
# ===================================================================
class TestStatusMappings(unittest.TestCase):
"""Test that status mappings are correct and complete."""
def test_jira_to_jotty_mapping(self):
"""JIRA_TO_JOTTY_STATUS should map all expected statuses."""
mapping = sync_module.JIRA_TO_JOTTY_STATUS
self.assertEqual(mapping["TO DO"], "todo")
self.assertEqual(mapping["IN PROGRESS"], "in_progress")
self.assertEqual(mapping["DONE"], "completed")
def test_jotty_to_jira_mapping(self):
"""JOTTY_TO_JIRA_STATUS should map all expected statuses."""
mapping = sync_module.JOTTY_TO_JIRA_STATUS
self.assertEqual(mapping["todo"], "TO DO")
self.assertEqual(mapping["in_progress"], "IN PROGRESS")
self.assertEqual(mapping["completed"], "DONE")
def test_extract_jira_key(self):
"""_extract_jira_key should extract PROJ-123 from text."""
self.assertEqual(
sync_module.SyncEngine._extract_jira_key("[PROJ-42] Implement auth"),
"PROJ-42",
)
def test_extract_jira_key_not_found(self):
"""_extract_jira_key should return None when no key is found."""
self.assertIsNone(
sync_module.SyncEngine._extract_jira_key("No Jira key here"),
)
# ===================================================================
# 8. Error Handling Tests
# ===================================================================
class TestErrorHandling(unittest.TestCase):
"""Test that errors are handled gracefully."""
@responses.activate
def test_jira_401_unauthorized(self):
"""JiraClient should raise HTTPError on 401."""
client = sync_module.JiraClient(
"https://myorg.atlassian.net",
"baduser",
"badtoken",
)
responses.add(
responses.POST,
"https://myorg.atlassian.net/rest/api/3/search/search",
json={"errorMessages": ["Unauthorized"], "status": 401},
status=401,
)
with self.assertRaises(Exception): # requests.HTTPError
client.search_stories('project = "PROJ"')
@responses.activate
def test_jotty_401_unauthorized(self):
"""JottyClient should raise HTTPError on 401."""
client = sync_module.JottyClient(
"https://jotty.example.com",
"ck_bad_key",
)
responses.add(
responses.GET,
"https://jotty.example.com/api/checklists",
json={"error": "Unauthorized"},
status=401,
)
with self.assertRaises(Exception): # requests.HTTPError
client.get_checklists()
# ===================================================================
# Run tests
# ===================================================================
if __name__ == "__main__":
unittest.main()