""" 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()